Compare commits
No commits in common. "main" and "v1.7.115-alpha" have entirely different histories.
main
...
v1.7.115-a
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@ -1,16 +1,16 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
<!-- What changed and why? -->
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
## Verification
|
## Changes
|
||||||
|
|
||||||
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
|
-
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Rust formatting/clippy/tests pass when backend code changed.
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
- [ ] Frontend type-check/build/tests pass when frontend code changed.
|
- [ ] Frontend builds (`npm run build`)
|
||||||
- [ ] App manifests validate when app packaging changed.
|
- [ ] Tests pass (`npm test`)
|
||||||
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
|
- [ ] Rust clippy clean (if backend changes)
|
||||||
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
|
- [ ] No new compiler warnings
|
||||||
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
|
- [ ] Tested on live server
|
||||||
|
|||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@ -8,11 +8,11 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
RUST_VERSION: stable
|
RUST_VERSION: stable
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 18
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Rust
|
name: Rust (fmt + clippy + test)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -28,17 +28,17 @@ jobs:
|
|||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Format
|
- name: Check formatting
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Test
|
- name: Tests
|
||||||
run: cargo test --all-features
|
run: cargo test --all-features
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend
|
name: Frontend (type-check + lint)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -52,31 +52,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: 'npm'
|
||||||
cache-dependency-path: neode-ui/package-lock.json
|
cache-dependency-path: neode-ui/package-lock.json
|
||||||
|
|
||||||
- name: Install
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
manifests:
|
|
||||||
name: App Manifests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Validate manifests
|
|
||||||
run: |
|
|
||||||
for manifest in apps/*/manifest.yml; do
|
|
||||||
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
|
|
||||||
done
|
|
||||||
|
|||||||
35
.gitignore
vendored
35
.gitignore
vendored
@ -1,9 +1,10 @@
|
|||||||
# SSH keys and sandbox copies
|
# SSH keys (sandbox copies)
|
||||||
.ssh/
|
.ssh/
|
||||||
|
|
||||||
# Rust build output
|
# Rust build output
|
||||||
target/
|
target/
|
||||||
**/target/
|
**/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -11,6 +12,7 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
package-lock.json
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
@ -26,46 +28,49 @@ build/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._*
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Environment and local overrides
|
# Environment and local overrides
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env.production
|
|
||||||
core/.env.production
|
|
||||||
scripts/deploy-config.sh
|
scripts/deploy-config.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
# Image / release artifacts
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
*.iso
|
*.iso
|
||||||
*.img
|
*.img
|
||||||
*.dmg
|
*.dmg
|
||||||
*.app
|
*.app
|
||||||
*.apk
|
|
||||||
*.keystore
|
|
||||||
*.s9pk
|
|
||||||
*.tar.gz
|
|
||||||
|
|
||||||
# Release artifacts live in release attachments, not Git history.
|
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||||
releases/**
|
releases/**
|
||||||
!releases/
|
!releases/
|
||||||
!releases/manifest.json
|
!releases/manifest.json
|
||||||
|
|
||||||
|
# macOS build output
|
||||||
|
build/macos/
|
||||||
|
|
||||||
# Image recipe output
|
# Image recipe output
|
||||||
image-recipe/output/
|
image-recipe/output/
|
||||||
image-recipe/*.iso
|
image-recipe/*.iso
|
||||||
image-recipe/*.img
|
image-recipe/*.img
|
||||||
|
|
||||||
# Loop tool artifacts
|
# Loop tool artifacts (created in every subdirectory)
|
||||||
*/loop/
|
*/loop/
|
||||||
loop/loop/
|
loop/loop/
|
||||||
loop/loop.log.bak
|
loop/loop.log.bak
|
||||||
@ -73,17 +78,19 @@ loop/loop.log.bak
|
|||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
# Resilience harness reports contain session cookies.
|
._*
|
||||||
|
|
||||||
|
# Resilience harness reports (generated, contains session cookies)
|
||||||
scripts/resilience/reports/
|
scripts/resilience/reports/
|
||||||
|
|
||||||
# Codex / pnpm / python caches / editor backups
|
# Codex / pnpm / python caches / editor backups
|
||||||
.codex
|
.codex
|
||||||
.codex-target-*/
|
.codex-target-*/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
.claude/
|
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.bak
|
*.bak
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||||
# app/docs asset path with a descriptive filename.
|
# app/docs asset path with a descriptive filename.
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
# Ingest Conflict Report
|
|
||||||
|
|
||||||
Mode: new (fresh bootstrap — no existing .planning/ context to check against)
|
|
||||||
Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present)
|
|
||||||
|
|
||||||
## Conflict Detection Report
|
|
||||||
|
|
||||||
### BLOCKERS (0)
|
|
||||||
|
|
||||||
(none)
|
|
||||||
|
|
||||||
### WARNINGS (0)
|
|
||||||
|
|
||||||
(none)
|
|
||||||
|
|
||||||
### INFO (4)
|
|
||||||
|
|
||||||
[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory
|
|
||||||
Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope
|
|
||||||
Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene.
|
|
||||||
|
|
||||||
[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults
|
|
||||||
Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field)
|
|
||||||
Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md.
|
|
||||||
|
|
||||||
[INFO] ADR numbering gap — ADR-010 absent from ingest set
|
|
||||||
Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010
|
|
||||||
Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record.
|
|
||||||
|
|
||||||
[INFO] Cross-reference graph is acyclic
|
|
||||||
Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs)
|
|
||||||
Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed.
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
# Archipelago
|
|
||||||
|
|
||||||
## What This Is
|
|
||||||
|
|
||||||
Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`)
|
|
||||||
plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes
|
|
||||||
with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning,
|
|
||||||
mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet
|
|
||||||
and is actively shipping v1.7.x alpha releases. This milestone drives it to the
|
|
||||||
**developer-ready app platform** north star.
|
|
||||||
|
|
||||||
## Core Value
|
|
||||||
|
|
||||||
A third-party developer can publish an app via the signed/decentralized registry and a user
|
|
||||||
can install it on their node — every app manifest-driven, manifests shipped via the signed
|
|
||||||
registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable.
|
|
||||||
|
|
||||||
## Current State (brownfield baseline, 2026-07-29)
|
|
||||||
|
|
||||||
- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met.
|
|
||||||
- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the
|
|
||||||
orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted.
|
|
||||||
- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing
|
|
||||||
ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized.
|
|
||||||
- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or
|
|
||||||
trust UX built. Developer CLI suite (`archy app …`) does not exist yet.
|
|
||||||
- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default.
|
|
||||||
- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the
|
|
||||||
remaining workstreams.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Validated
|
|
||||||
|
|
||||||
- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/
|
|
||||||
reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23
|
|
||||||
- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A)
|
|
||||||
- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete)
|
|
||||||
|
|
||||||
### Active
|
|
||||||
|
|
||||||
See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT,
|
|
||||||
all mapped to phases in `.planning/ROADMAP.md`.
|
|
||||||
|
|
||||||
### Out of Scope
|
|
||||||
|
|
||||||
- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009)
|
|
||||||
- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted
|
|
||||||
- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006)
|
|
||||||
- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011)
|
|
||||||
- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later
|
|
||||||
- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests,
|
|
||||||
`tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans.
|
|
||||||
- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list:
|
|
||||||
`docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` +
|
|
||||||
`.planning/codebase/CONCERNS.md`.
|
|
||||||
- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors
|
|
||||||
swallowed; reconciler has no flap observability and no failed-unit self-healing; generated
|
|
||||||
AppArmor profiles are never applied; multinode test harness curl calls lack timeouts;
|
|
||||||
SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp).
|
|
||||||
- Fleet is live and OTA-updated; all destructive verification happens on designated test
|
|
||||||
nodes per the deploy roster — never uninvited on in-use nodes.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged);
|
|
||||||
mandatory container security defaults enforced at manifest level (ADR-009)
|
|
||||||
- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/<app>`,
|
|
||||||
secrets, credentials, ports, adoption container names; always a rollback path
|
|
||||||
- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node,
|
|
||||||
not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes
|
|
||||||
- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit
|
|
||||||
path; deploy to the dev pair before any OTA; never commit secrets
|
|
||||||
- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend;
|
|
||||||
Quadlet/systemd-user container units; Ed25519-signed release artifacts
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
<decisions>
|
|
||||||
|
|
||||||
All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are
|
|
||||||
non-negotiable inputs to planning and cannot be overridden without a new ADR.
|
|
||||||
|
|
||||||
| ID | Decision | Scope |
|
|
||||||
|----|----------|-------|
|
|
||||||
| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime |
|
|
||||||
| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity |
|
|
||||||
| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery |
|
|
||||||
| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport |
|
|
||||||
| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups |
|
|
||||||
| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace |
|
|
||||||
| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust |
|
|
||||||
| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys |
|
|
||||||
| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security |
|
|
||||||
| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync |
|
|
||||||
|
|
||||||
(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.)
|
|
||||||
|
|
||||||
</decisions>
|
|
||||||
|
|
||||||
Milestone-level decisions:
|
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
|
||||||
|----------|-----------|---------|
|
|
||||||
| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship |
|
|
||||||
| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending |
|
|
||||||
| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good |
|
|
||||||
| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending |
|
|
||||||
|
|
||||||
---
|
|
||||||
*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping*
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform)
|
|
||||||
|
|
||||||
**Defined:** 2026-07-29
|
|
||||||
**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
|
|
||||||
|
|
||||||
No PRDs existed in the ingest set; these requirements are derived from the master plan's
|
|
||||||
declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`,
|
|
||||||
`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from
|
|
||||||
`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built.
|
|
||||||
|
|
||||||
## v1 Requirements
|
|
||||||
|
|
||||||
### Federation & Mesh Hardening (FED)
|
|
||||||
|
|
||||||
- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
|
|
||||||
- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
|
|
||||||
- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
|
|
||||||
- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
|
|
||||||
- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
|
|
||||||
- [ ] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
|
|
||||||
|
|
||||||
### UI Performance (PERF)
|
|
||||||
|
|
||||||
- [ ] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed
|
|
||||||
- [ ] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session
|
|
||||||
- [ ] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box
|
|
||||||
|
|
||||||
### Multinode Verification (MNODE)
|
|
||||||
|
|
||||||
- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy
|
|
||||||
- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs)
|
|
||||||
- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles
|
|
||||||
|
|
||||||
### Lifecycle Perfection (LIFE)
|
|
||||||
|
|
||||||
- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm)
|
|
||||||
- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping
|
|
||||||
- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output
|
|
||||||
- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy
|
|
||||||
- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar
|
|
||||||
|
|
||||||
### Registry-Distributed Manifests (REG)
|
|
||||||
|
|
||||||
- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback
|
|
||||||
- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync
|
|
||||||
|
|
||||||
### Security Enforcement (SEC)
|
|
||||||
|
|
||||||
- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides
|
|
||||||
- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps
|
|
||||||
|
|
||||||
### Developer Tooling (DEV)
|
|
||||||
|
|
||||||
- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules)
|
|
||||||
- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces
|
|
||||||
- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`)
|
|
||||||
- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs
|
|
||||||
|
|
||||||
### Decentralized Marketplace (MKT)
|
|
||||||
|
|
||||||
- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling
|
|
||||||
- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring
|
|
||||||
- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed
|
|
||||||
- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees
|
|
||||||
|
|
||||||
## v2 Requirements
|
|
||||||
|
|
||||||
Deferred to a future milestone. Tracked but not in the current roadmap.
|
|
||||||
|
|
||||||
### Distribution Backbone (DIST)
|
|
||||||
|
|
||||||
- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog)
|
|
||||||
|
|
||||||
### Fleet & Hardening (FLEET)
|
|
||||||
|
|
||||||
- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`)
|
|
||||||
- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage
|
|
||||||
- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/`
|
|
||||||
- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop)
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
| Feature | Reason |
|
|
||||||
|---------|--------|
|
|
||||||
| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 |
|
|
||||||
| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted |
|
|
||||||
| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace |
|
|
||||||
| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown |
|
|
||||||
| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided |
|
|
||||||
|
|
||||||
## Traceability
|
|
||||||
|
|
||||||
Which phases cover which requirements. Updated during roadmap creation.
|
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
|
||||||
|-------------|-------|--------|
|
|
||||||
| FED-01 | Phase 1 | Pending |
|
|
||||||
| FED-02 | Phase 1 | Pending |
|
|
||||||
| FED-03 | Phase 1 | Pending |
|
|
||||||
| FED-04 | Phase 1 | Pending |
|
|
||||||
| FED-05 | Phase 1 | Pending |
|
|
||||||
| FED-06 | Phase 1 | Pending |
|
|
||||||
| PERF-01 | Phase 2 | Pending |
|
|
||||||
| PERF-02 | Phase 2 | Pending |
|
|
||||||
| PERF-03 | Phase 2 | Pending |
|
|
||||||
| MNODE-01 | Phase 3 | Pending |
|
|
||||||
| MNODE-02 | Phase 3 | Pending |
|
|
||||||
| MNODE-03 | Phase 3 | Pending |
|
|
||||||
| LIFE-01 | Phase 4 | Pending |
|
|
||||||
| LIFE-02 | Phase 4 | Pending |
|
|
||||||
| LIFE-03 | Phase 4 | Pending |
|
|
||||||
| LIFE-04 | Phase 4 | Pending |
|
|
||||||
| LIFE-05 | Phase 4 | Pending |
|
|
||||||
| REG-01 | Phase 5 | Pending |
|
|
||||||
| REG-02 | Phase 5 | Pending |
|
|
||||||
| SEC-01 | Phase 6 | Pending |
|
|
||||||
| SEC-02 | Phase 6 | Pending |
|
|
||||||
| DEV-01 | Phase 7 | Pending |
|
|
||||||
| DEV-02 | Phase 7 | Pending |
|
|
||||||
| DEV-03 | Phase 7 | Pending |
|
|
||||||
| DEV-04 | Phase 7 | Pending |
|
|
||||||
| MKT-01 | Phase 8 | Pending |
|
|
||||||
| MKT-02 | Phase 8 | Pending |
|
|
||||||
| MKT-03 | Phase 8 | Pending |
|
|
||||||
| MKT-04 | Phase 8 | Pending |
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- v1 requirements: 29 total
|
|
||||||
- Mapped to phases: 29
|
|
||||||
- Unmapped: 0
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-07-29*
|
|
||||||
*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 1–2*
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Brownfield milestone starting from a green single-node production gate (5/5 on .228,
|
|
||||||
2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync,
|
|
||||||
messaging parity), fix the UI slowness users feel on every tab switch, prove the platform
|
|
||||||
across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet
|
|
||||||
default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA
|
|
||||||
disk files to the signed registry, harden manifest security enforcement to the full
|
|
||||||
ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr
|
|
||||||
marketplace — ending at the north star: a third-party developer publishes an app via the
|
|
||||||
signed/decentralized registry and a user installs it on their node.
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
**Phase Numbering:**
|
|
||||||
- Integer phases (1, 2, 3): Planned milestone work
|
|
||||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
|
||||||
|
|
||||||
- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes
|
|
||||||
- [ ] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed
|
|
||||||
- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks
|
|
||||||
- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress
|
|
||||||
- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution
|
|
||||||
- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied
|
|
||||||
- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide
|
|
||||||
- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end
|
|
||||||
|
|
||||||
## Phase Details
|
|
||||||
|
|
||||||
### Phase 1: Federation & Mesh Hardening
|
|
||||||
**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs
|
|
||||||
**Depends on**: Nothing (first phase)
|
|
||||||
**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05, FED-06
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason
|
|
||||||
2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping
|
|
||||||
3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator
|
|
||||||
4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal)
|
|
||||||
5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy
|
|
||||||
6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears
|
|
||||||
**Plans**: 10 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01)
|
|
||||||
- [ ] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04)
|
|
||||||
- [ ] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06)
|
|
||||||
- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05)
|
|
||||||
- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02)
|
|
||||||
- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05)
|
|
||||||
- [ ] 01-07-PLAN.md — Channel-open request messaging over the mesh (FED-05)
|
|
||||||
- [ ] 01-08-PLAN.md — Channel-open UX: own URI, trusted-node picker, meshed-peer requests (FED-05)
|
|
||||||
- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03)
|
|
||||||
- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06)
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 2: UI Performance
|
|
||||||
**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts
|
|
||||||
**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1)
|
|
||||||
**Requirements**: PERF-01, PERF-02, PERF-03
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land
|
|
||||||
2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session
|
|
||||||
3. Secondary screens open without a blocking full reload; repeat visits are instant
|
|
||||||
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 3: Multinode Verification Pass
|
|
||||||
**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion
|
|
||||||
**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide)
|
|
||||||
**Requirements**: MNODE-01, MNODE-02, MNODE-03
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node
|
|
||||||
2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang
|
|
||||||
3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed
|
|
||||||
4. The on-air mesh suite passes between two radio-equipped nodes over real RF
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 4: Lifecycle Perfection & Quadlet Default
|
|
||||||
**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery
|
|
||||||
**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean)
|
|
||||||
**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild
|
|
||||||
2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention
|
|
||||||
3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible
|
|
||||||
4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier
|
|
||||||
5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 5: Registry-Distributed Manifests
|
|
||||||
**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change
|
|
||||||
**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel)
|
|
||||||
**Requirements**: REG-01, REG-02
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps)
|
|
||||||
2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest
|
|
||||||
3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 6: Manifest Security Enforcement
|
|
||||||
**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime
|
|
||||||
**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through)
|
|
||||||
**Requirements**: SEC-01, SEC-02
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation
|
|
||||||
2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail
|
|
||||||
3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored
|
|
||||||
4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides)
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 7: Developer Tooling CLI
|
|
||||||
**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals
|
|
||||||
**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set)
|
|
||||||
**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install
|
|
||||||
2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node
|
|
||||||
3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI
|
|
||||||
4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 8: Decentralized Marketplace
|
|
||||||
**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes
|
|
||||||
**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6)
|
|
||||||
**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling
|
|
||||||
2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring
|
|
||||||
3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed
|
|
||||||
4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
**Execution Order:**
|
|
||||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
|
|
||||||
(Phases 1 and 2 are independent and may be worked in parallel.)
|
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
|
||||||
|-------|----------------|--------|-----------|
|
|
||||||
| 1. Federation & Mesh Hardening | 0/10 | Planned | - |
|
|
||||||
| 2. UI Performance | 0/TBD | Not started | - |
|
|
||||||
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
|
|
||||||
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
|
||||||
| 5. Registry-Distributed Manifests | 0/TBD | Not started | - |
|
|
||||||
| 6. Manifest Security Enforcement | 0/TBD | Not started | - |
|
|
||||||
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
|
|
||||||
| 8. Decentralized Marketplace | 0/TBD | Not started | - |
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v1.8.0
|
|
||||||
milestone_name: milestone
|
|
||||||
current_phase: 1
|
|
||||||
current_phase_name: Federation & Mesh Hardening
|
|
||||||
status: planning
|
|
||||||
stopped_at: Phase 1 planned (10 plans, checker-approved)
|
|
||||||
last_updated: "2026-07-29T16:20:37.730Z"
|
|
||||||
last_activity: 2026-07-29
|
|
||||||
last_activity_desc: "Completed quick task 260729-fw7: mesh hop graphic redesign (branded HopVizModal, vertical mobile layout)"
|
|
||||||
progress:
|
|
||||||
total_phases: 8
|
|
||||||
completed_phases: 0
|
|
||||||
total_plans: 10
|
|
||||||
completed_plans: 0
|
|
||||||
percent: 0
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Project Reference
|
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-07-29)
|
|
||||||
|
|
||||||
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
|
|
||||||
**Current focus:** Phase 1 — Federation & Mesh Hardening
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 1 of 8 (Federation & Mesh Hardening)
|
|
||||||
Plan: 0 of TBD in current phase
|
|
||||||
Status: Ready to plan
|
|
||||||
Last activity: 2026-07-29 — Completed quick task 260729-fw7: mesh hop graphic redesign (branded HopVizModal, vertical mobile layout)
|
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
**Velocity:**
|
|
||||||
|
|
||||||
- Total plans completed: 0
|
|
||||||
- Average duration: —
|
|
||||||
- Total execution time: —
|
|
||||||
|
|
||||||
**By Phase:**
|
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
|
||||||
|-------|-------|-------|----------|
|
|
||||||
| - | - | - | - |
|
|
||||||
|
|
||||||
## Accumulated Context
|
|
||||||
|
|
||||||
### Roadmap Evolution
|
|
||||||
|
|
||||||
- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down
|
|
||||||
- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down
|
|
||||||
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
|
|
||||||
- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
|
|
||||||
Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block + milestone decisions table). Recent decisions affecting current work:
|
|
||||||
|
|
||||||
- Milestone version = 1.8.0-alpha (decided 2026-07-08)
|
|
||||||
- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted)
|
|
||||||
- Workstream D (DHT distribution) deferred to v2 — design-only backlog
|
|
||||||
- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc)
|
|
||||||
|
|
||||||
### Pending Todos
|
|
||||||
|
|
||||||
None yet.
|
|
||||||
|
|
||||||
### Blockers/Concerns
|
|
||||||
|
|
||||||
- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind
|
|
||||||
- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running
|
|
||||||
- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call
|
|
||||||
- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands
|
|
||||||
- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests)
|
|
||||||
|
|
||||||
### Quick Tasks Completed
|
|
||||||
|
|
||||||
| # | Description | Date | Commit | Directory |
|
|
||||||
|---|-------------|------|--------|-----------|
|
|
||||||
| 260729-fw7 | improve mesh message hop graphic/animation: balanced desktop sizing, vertical mobile layout, archipelago branding | 2026-07-29 | ac09fc5d | [260729-fw7-improve-mesh-message-hop-graphic-animati](./quick/260729-fw7-improve-mesh-message-hop-graphic-animati/) |
|
|
||||||
|
|
||||||
## Deferred Items
|
|
||||||
|
|
||||||
| Category | Item | Status | Deferred At |
|
|
||||||
|----------|------|--------|-------------|
|
|
||||||
| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 |
|
|
||||||
| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 |
|
|
||||||
| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 |
|
|
||||||
| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 |
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
|
|
||||||
Last session: 2026-07-29T16:20:37.711Z
|
|
||||||
Stopped at: Phase 1 planned (10 plans, checker-approved)
|
|
||||||
Resume file: .planning/phases/01-federation-mesh-hardening/01-01-PLAN.md
|
|
||||||
@ -1,333 +0,0 @@
|
|||||||
<!-- refreshed: 2026-07-29 -->
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Frontend Layer (Vue 3) │
|
|
||||||
│ `neode-ui/src` (TypeScript + SPA) │
|
|
||||||
│ Routes → Views → Components → Composables → RPC Client │
|
|
||||||
└────────────────┬─────────────────────────────────────────────┘
|
|
||||||
│ WebSocket + HTTP(S)
|
|
||||||
│ JSON-RPC 2.0 protocol
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ HTTP Server Layer (Hyper) │
|
|
||||||
│ `core/archipelago/src/server.rs` │
|
|
||||||
│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │
|
|
||||||
└────────────────┬─────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────┴──────────┬──────────────────┐
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌─────────┐ ┌──────────┐ ┌────────────┐
|
|
||||||
│ WebSocket │ RPC │ │ Content │
|
|
||||||
│ Handler │ Handler │ │ Proxy │
|
|
||||||
│ (state sync) │ (methods)│ │ (app URIs) │
|
|
||||||
└─────────┘ └──────────┘ └────────────┘
|
|
||||||
│ │
|
|
||||||
└──────────┬───────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Service Layer (Async Tasks) │
|
|
||||||
│ `core/archipelago/src/api/rpc/*` │
|
|
||||||
│ │
|
|
||||||
│ • auth, identity, secrets │
|
|
||||||
│ • container orchestration │
|
|
||||||
│ • bitcoin, lightning, wallet │
|
|
||||||
│ • mesh, federation, FIPS │
|
|
||||||
│ • content, backup, settings │
|
|
||||||
└─────────────┬───────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────┼───────────┬──────────────┐
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐
|
|
||||||
│Container│ │ State │ │BlobStore
|
|
||||||
│Orch. │ │Manager │ │ │ │Identity │
|
|
||||||
│(Podman) │ │(Broadcast
|
|
||||||
│ │ │ channels)│ │ ContentClient Manager │
|
|
||||||
└─────────┘ └──────────┘ └────────┘ └──────────┘
|
|
||||||
│ │ │ │
|
|
||||||
└───────────┼───────────┼─────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Persistent Storage Layer │
|
|
||||||
│ │
|
|
||||||
│ • Data directory files (YAML/JSON) │
|
|
||||||
│ • SQLite (session store) │
|
|
||||||
│ • Blob store (content-addressed) │
|
|
||||||
│ • Podman container state │
|
|
||||||
│ • Secret vaults (encrypted) │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Responsibilities
|
|
||||||
|
|
||||||
| Component | Responsibility | File |
|
|
||||||
|-----------|----------------|------|
|
|
||||||
| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` |
|
|
||||||
| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` |
|
|
||||||
| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` |
|
|
||||||
| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` |
|
|
||||||
| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` |
|
|
||||||
| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` |
|
|
||||||
| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` |
|
|
||||||
| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` |
|
|
||||||
| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` |
|
|
||||||
| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` |
|
|
||||||
| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` |
|
|
||||||
|
|
||||||
## Pattern Overview
|
|
||||||
|
|
||||||
**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization.
|
|
||||||
|
|
||||||
**Key Characteristics:**
|
|
||||||
- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work
|
|
||||||
- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint
|
|
||||||
- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients
|
|
||||||
- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions)
|
|
||||||
- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets)
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
**HTTP/Transport Layer:**
|
|
||||||
- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket
|
|
||||||
- Location: `core/archipelago/src/server.rs`
|
|
||||||
- Contains: Hyper listener, TCP accept loop, connection state tracking
|
|
||||||
- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl)
|
|
||||||
- Used by: All external clients (web UI, companion app, API consumers)
|
|
||||||
|
|
||||||
**Request Routing & Auth Layer:**
|
|
||||||
- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login
|
|
||||||
- Location: `core/archipelago/src/api/` (handler + rpc submodules)
|
|
||||||
- Contains: Route matching, middleware chain, cookie extraction, error formatting
|
|
||||||
- Depends on: Server, StateManager, SessionStore
|
|
||||||
- Used by: All request paths; gates API access
|
|
||||||
|
|
||||||
**RPC Dispatch Layer:**
|
|
||||||
- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses
|
|
||||||
- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.)
|
|
||||||
- Contains: Method table, parameter validation, response formatting, rate limit checks
|
|
||||||
- Depends on: All service modules
|
|
||||||
- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools
|
|
||||||
|
|
||||||
**Service Layer:**
|
|
||||||
- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery
|
|
||||||
- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks)
|
|
||||||
- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.)
|
|
||||||
- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS)
|
|
||||||
- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks)
|
|
||||||
|
|
||||||
**State Management Layer:**
|
|
||||||
- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots
|
|
||||||
- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs)
|
|
||||||
- Contains: RwLock<DataModel>, broadcast channel, revision counter
|
|
||||||
- Depends on: DataModel (serde-serializable struct tree)
|
|
||||||
- Used by: All services that mutate state (container ops, auth, settings)
|
|
||||||
|
|
||||||
**Container Orchestration Layer:**
|
|
||||||
- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption
|
|
||||||
- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.)
|
|
||||||
- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking
|
|
||||||
- Depends on: Podman CLI + socket, config parser, image registries, local filesystem
|
|
||||||
- Used by: RPC container.* methods, BootReconciler loop, crash recovery
|
|
||||||
|
|
||||||
**Frontend Layer (Vue 3):**
|
|
||||||
- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input
|
|
||||||
- Location: `neode-ui/src/`
|
|
||||||
- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router
|
|
||||||
- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps)
|
|
||||||
- Used by: Browser clients (desktop, mobile, companion app via WebView)
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Primary Request Path (User Action → Backend → State Sync)
|
|
||||||
|
|
||||||
1. **Frontend user interaction** (click button, type input) → Vue component event handler
|
|
||||||
- Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue`
|
|
||||||
|
|
||||||
2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`)
|
|
||||||
- Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`)
|
|
||||||
|
|
||||||
3. **RPC client serializes → HTTP/WebSocket POST to /api/v0**
|
|
||||||
- Location: `neode-ui/src/api/rpc-client.ts`
|
|
||||||
- Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }`
|
|
||||||
|
|
||||||
4. **HTTP Server receives, routes to ApiHandler**
|
|
||||||
- Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch)
|
|
||||||
|
|
||||||
5. **ApiHandler checks auth**, extracts body, calls RpcHandler
|
|
||||||
- Location: `core/archipelago/src/api/handler/mod.rs:handle_request()`
|
|
||||||
|
|
||||||
6. **RpcHandler dispatches by method name** to specific RPC module
|
|
||||||
- Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()`
|
|
||||||
|
|
||||||
7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state)
|
|
||||||
- Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator)
|
|
||||||
|
|
||||||
8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers
|
|
||||||
- Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel
|
|
||||||
- All connected WebSocket clients receive `{ rev: N, data: {...} }` update
|
|
||||||
|
|
||||||
9. **Frontend receives state update**, updates Pinia stores, re-renders UI
|
|
||||||
- Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update
|
|
||||||
|
|
||||||
**State Management:**
|
|
||||||
- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock
|
|
||||||
- All writes go through `update_data()` which acquires write-lock + increments revision
|
|
||||||
- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest)
|
|
||||||
- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer)
|
|
||||||
|
|
||||||
### Secondary Flow: Scheduled Reconciliation (Convergence Loop)
|
|
||||||
|
|
||||||
1. **BootReconciler spawned at startup** in `main.rs`
|
|
||||||
- Location: `core/archipelago/src/main.rs` (line ~338-348)
|
|
||||||
|
|
||||||
2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical)
|
|
||||||
- Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()`
|
|
||||||
|
|
||||||
3. **Compares desired manifests (disk + registry catalog) vs actual Podman state**
|
|
||||||
- Looks for: containers missing, containers orphaned, image updates, secret changes
|
|
||||||
|
|
||||||
4. **Applies remediation** (create, delete, restart containers)
|
|
||||||
- Calls: orchestrator.reconcile_*() methods
|
|
||||||
|
|
||||||
5. **Logs changes, broadcasts state update if anything changed**
|
|
||||||
- Frontend receives update, shows user the reconciled app state
|
|
||||||
|
|
||||||
This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges.
|
|
||||||
|
|
||||||
## Key Abstractions
|
|
||||||
|
|
||||||
**ContainerOrchestrator trait:**
|
|
||||||
- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist
|
|
||||||
- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs`
|
|
||||||
- Pattern: Trait-based strategy; RpcHandler holds `Arc<dyn ContainerOrchestrator>`, switches at runtime
|
|
||||||
- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade
|
|
||||||
|
|
||||||
**Manifest (YAML-based declarative app):**
|
|
||||||
- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file
|
|
||||||
- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs
|
|
||||||
- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`)
|
|
||||||
- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator
|
|
||||||
|
|
||||||
**RPC Method Modules:**
|
|
||||||
- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.)
|
|
||||||
- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs`
|
|
||||||
- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result<Response>`
|
|
||||||
- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit)
|
|
||||||
|
|
||||||
**BlobStore (Content-Addressed):**
|
|
||||||
- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access
|
|
||||||
- Examples: Used by mesh.send-content, federation attachments, backup archives
|
|
||||||
- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash
|
|
||||||
- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs`
|
|
||||||
|
|
||||||
**StateManager + DataModel:**
|
|
||||||
- Purpose: Single source of truth for UI state; broadcast updates to all clients
|
|
||||||
- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency
|
|
||||||
- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately
|
|
||||||
- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug
|
|
||||||
|
|
||||||
**Session Store:**
|
|
||||||
- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping)
|
|
||||||
- Examples: SQLite-backed or in-memory store
|
|
||||||
- Pattern: Session token issued at login, validated on each request, expires after TTL
|
|
||||||
- Used by: ApiHandler auth check, rate limiter (per IP + per user)
|
|
||||||
|
|
||||||
## Entry Points
|
|
||||||
|
|
||||||
**Backend Daemon (Binary):**
|
|
||||||
- Location: `core/archipelago/src/main.rs`
|
|
||||||
- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node
|
|
||||||
- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks
|
|
||||||
- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server
|
|
||||||
|
|
||||||
**Frontend SPA:**
|
|
||||||
- Location: `neode-ui/src/main.ts`
|
|
||||||
- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`)
|
|
||||||
- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend
|
|
||||||
- Key setup: Mount app → router ready → fetch initial state → subscribe to updates
|
|
||||||
|
|
||||||
**RPC Endpoints (HTTP + WebSocket):**
|
|
||||||
- Location: `core/archipelago/src/api/` (handler routes requests here)
|
|
||||||
- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade)
|
|
||||||
- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.)
|
|
||||||
- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}`
|
|
||||||
|
|
||||||
**Background Tasks (Spawned at startup):**
|
|
||||||
- BootReconciler: Periodic manifest reconciliation loop
|
|
||||||
- Health Monitor: Periodic app health checks + restart
|
|
||||||
- Update Scheduler: Periodic app update checks
|
|
||||||
- Mesh Service: P2P mesh listener + sender (federation, LoRa)
|
|
||||||
- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers
|
|
||||||
- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber
|
|
||||||
- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks)
|
|
||||||
|
|
||||||
## Architectural Constraints
|
|
||||||
|
|
||||||
- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking)
|
|
||||||
- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design)
|
|
||||||
- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge
|
|
||||||
- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks)
|
|
||||||
- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory)
|
|
||||||
- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies)
|
|
||||||
- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler
|
|
||||||
|
|
||||||
## Anti-Patterns
|
|
||||||
|
|
||||||
### Circular RPC Dispatches
|
|
||||||
|
|
||||||
**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login)
|
|
||||||
**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug
|
|
||||||
**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount).
|
|
||||||
|
|
||||||
### Synchronous blocking in RPC handlers
|
|
||||||
|
|
||||||
**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop
|
|
||||||
**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users
|
|
||||||
**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples.
|
|
||||||
|
|
||||||
### Hardcoding paths in app RPC modules
|
|
||||||
|
|
||||||
**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir`
|
|
||||||
**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found"
|
|
||||||
**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern.
|
|
||||||
|
|
||||||
### Frontend state outside Pinia stores
|
|
||||||
|
|
||||||
**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data
|
|
||||||
**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall
|
|
||||||
**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`.
|
|
||||||
|
|
||||||
### Not handling WebSocket reconnection
|
|
||||||
|
|
||||||
**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state
|
|
||||||
**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens
|
|
||||||
**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages.
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes)
|
|
||||||
- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec
|
|
||||||
- Service layer: Use `anyhow::Result<T>` + `?` operator for early exit; convert to `RpcError` at handler boundary
|
|
||||||
- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app)
|
|
||||||
|
|
||||||
**Critical paths:**
|
|
||||||
- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames)
|
|
||||||
- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon)
|
|
||||||
- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries)
|
|
||||||
- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Architecture analysis: 2026-07-29*
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
# Codebase Concerns
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## Tech Debt
|
|
||||||
|
|
||||||
**Federation node removal tombstone gap:**
|
|
||||||
- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it.
|
|
||||||
- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300`
|
|
||||||
- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections.
|
|
||||||
- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles.
|
|
||||||
|
|
||||||
**Container reconciler observability gap:**
|
|
||||||
- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes.
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs`
|
|
||||||
- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop.
|
|
||||||
- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output.
|
|
||||||
|
|
||||||
**Failed systemd unit self-healing gap:**
|
|
||||||
- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts.
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop)
|
|
||||||
- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator.
|
|
||||||
- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed <unit>` + `start`; add backoff to avoid busy-loop on persistent failures.
|
|
||||||
|
|
||||||
**Bitcoin RPC credentials not retrieved from config/secrets:**
|
|
||||||
- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store.
|
|
||||||
- Files: `core/container/src/bitcoin_simulator.rs:155-165`
|
|
||||||
- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail.
|
|
||||||
- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation.
|
|
||||||
|
|
||||||
**Container security policies not wired in:**
|
|
||||||
- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers.
|
|
||||||
- Files: `core/security/src/container_policies.rs:63-75`
|
|
||||||
- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints.
|
|
||||||
- Fix approach: Pass `--security-opt apparmor=<profile>` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly.
|
|
||||||
|
|
||||||
**Dynamic resource adjustment not implemented:**
|
|
||||||
- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift.
|
|
||||||
- Files: `core/performance/src/resource_manager.rs:86-88`
|
|
||||||
- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically.
|
|
||||||
- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+).
|
|
||||||
|
|
||||||
## Known Bugs
|
|
||||||
|
|
||||||
**Multinode RPC robustness gap:**
|
|
||||||
- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback.
|
|
||||||
- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`)
|
|
||||||
- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever.
|
|
||||||
- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery.
|
|
||||||
- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify.
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
**Secrets environment variable exposure risk:**
|
|
||||||
- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc/<pid>/environ` and potentially logged.
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs`
|
|
||||||
- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values.
|
|
||||||
- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access.
|
|
||||||
|
|
||||||
**Federation DID validation incomplete:**
|
|
||||||
- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs.
|
|
||||||
- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs`
|
|
||||||
- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers.
|
|
||||||
- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers.
|
|
||||||
|
|
||||||
**AppArmor profiles overly permissive:**
|
|
||||||
- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs.
|
|
||||||
- Files: `core/security/src/container_policies.rs:46-54`
|
|
||||||
- Current mitigation: None (profiles not applied).
|
|
||||||
- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod.
|
|
||||||
|
|
||||||
## Performance Bottlenecks
|
|
||||||
|
|
||||||
**Container thrashing during reconcile:**
|
|
||||||
- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts.
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine)
|
|
||||||
- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units.
|
|
||||||
- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup.
|
|
||||||
|
|
||||||
**Reconciler churn on boot:**
|
|
||||||
- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability.
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs`
|
|
||||||
- Cause: Multi-pass reconciliation + no incremental diff detection.
|
|
||||||
- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path.
|
|
||||||
|
|
||||||
**Bitcoin IBD on .198 stalled (disk I/O):**
|
|
||||||
- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5).
|
|
||||||
- Files: `tests/multinode-testing-plan.md` (documented issue)
|
|
||||||
- Cause: Undersized/slow disk; concurrent workload.
|
|
||||||
- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue.
|
|
||||||
|
|
||||||
## Fragile Areas
|
|
||||||
|
|
||||||
**Uninstall + reinstall lifecycle:**
|
|
||||||
- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue`
|
|
||||||
- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in.
|
|
||||||
- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll.
|
|
||||||
- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`.
|
|
||||||
|
|
||||||
**Production orchestrator state machine:**
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines)
|
|
||||||
- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved.
|
|
||||||
- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution.
|
|
||||||
- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate.
|
|
||||||
|
|
||||||
**Mesh radio configuration + boot race:**
|
|
||||||
- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats`
|
|
||||||
- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware.
|
|
||||||
- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues.
|
|
||||||
- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate).
|
|
||||||
|
|
||||||
**Lightning payment state machine:**
|
|
||||||
- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()`
|
|
||||||
- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop.
|
|
||||||
- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release.
|
|
||||||
- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate.
|
|
||||||
|
|
||||||
## Scaling Limits
|
|
||||||
|
|
||||||
**Uninstall progress bar truthfulness:**
|
|
||||||
- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity).
|
|
||||||
- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration.
|
|
||||||
- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app).
|
|
||||||
|
|
||||||
**Federation node list deduplication on disk bloat:**
|
|
||||||
- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation.
|
|
||||||
- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible.
|
|
||||||
- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load).
|
|
||||||
|
|
||||||
**Lifecycle gate iteration count:**
|
|
||||||
- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228.
|
|
||||||
- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual.
|
|
||||||
- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention).
|
|
||||||
|
|
||||||
## Dependencies at Risk
|
|
||||||
|
|
||||||
**Reticulum transport daemon process group:**
|
|
||||||
- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions.
|
|
||||||
- Impact: Stale reticulum processes accumulating over time; resource leaks on node.
|
|
||||||
- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment.
|
|
||||||
|
|
||||||
**Podman socket mount security model:**
|
|
||||||
- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied).
|
|
||||||
- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer)
|
|
||||||
- Impact: A compromised app with podman socket access can start/stop/delete any container on the node.
|
|
||||||
- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired.
|
|
||||||
|
|
||||||
**Bitcoin version multi-version branch not fleet-wide:**
|
|
||||||
- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version.
|
|
||||||
- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation.
|
|
||||||
- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing.
|
|
||||||
|
|
||||||
## Missing Critical Features
|
|
||||||
|
|
||||||
**Developer tooling CLI suite:**
|
|
||||||
- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches.
|
|
||||||
- Blocks: External marketplace (workstream C); external developer onboarding.
|
|
||||||
- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5.
|
|
||||||
|
|
||||||
**Manifest-distributed registry flip:**
|
|
||||||
- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed.
|
|
||||||
- Blocks: Cannot confidently add/bump apps without re-signing the catalog.
|
|
||||||
- Status: Code ready; flip awaits authorization + timing call from user.
|
|
||||||
|
|
||||||
**Phase-3 Quadlet default-flip:**
|
|
||||||
- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only.
|
|
||||||
- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F).
|
|
||||||
- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes.
|
|
||||||
|
|
||||||
## Test Coverage Gaps
|
|
||||||
|
|
||||||
**~30 apps with zero app-specific assertions:**
|
|
||||||
- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running."
|
|
||||||
- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage)
|
|
||||||
- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them.
|
|
||||||
- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker.
|
|
||||||
- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites.
|
|
||||||
|
|
||||||
**Progress UI assertions incomplete:**
|
|
||||||
- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate.
|
|
||||||
- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events)
|
|
||||||
- Risk: Silent hangs or fake progress bars are invisible to the gate.
|
|
||||||
- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F.
|
|
||||||
- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in).
|
|
||||||
|
|
||||||
**All-apps matrix in cascade gate:**
|
|
||||||
- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps.
|
|
||||||
- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant)
|
|
||||||
- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected.
|
|
||||||
- Priority: High — part of workstream F definition of done.
|
|
||||||
- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).*
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
# Coding Conventions
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## Naming Patterns
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`)
|
|
||||||
- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`)
|
|
||||||
- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests
|
|
||||||
- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`)
|
|
||||||
|
|
||||||
**Functions:**
|
|
||||||
- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`)
|
|
||||||
- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports
|
|
||||||
- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory
|
|
||||||
- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions)
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`)
|
|
||||||
- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`)
|
|
||||||
- Computed properties: camelCase, explicit `.value` suffix in templates when needed
|
|
||||||
- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref<boolean>`)
|
|
||||||
|
|
||||||
**Types:**
|
|
||||||
- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`)
|
|
||||||
- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`)
|
|
||||||
- Component props: typed with `defineProps<{ ... }>()` syntax in `<script setup>`
|
|
||||||
- Rust: PascalCase for structs and enums, snake_case for fields within them
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
**Formatting:**
|
|
||||||
- No global Prettier config; code style follows project patterns incrementally
|
|
||||||
- Vue components: single-file components (`.vue`) with `<template>`, `<script setup>`, optional `<style scoped>`
|
|
||||||
- TypeScript: indentation is 2 spaces (visible in `vitest.config.ts`, Vue components, test files)
|
|
||||||
- Line width: no strict enforcement observed; pragmatic wrapping around 80–100 characters
|
|
||||||
- Arrow functions preferred for short callbacks: `(x) => x * 2`
|
|
||||||
- Template strings for multi-line formatting
|
|
||||||
|
|
||||||
**Linting:**
|
|
||||||
- No `.eslintrc` detected at repo root or `neode-ui/` level
|
|
||||||
- Rust: Clippy allowances declared at crate level in `main.rs` (`#![allow(...)]`) to suppress stylistic lints and focus CI on correctness issues
|
|
||||||
- Examples of suppressed Clippy lints: `too_many_arguments`, `type_complexity`, `enum_variant_names`, `unused_io_amount`
|
|
||||||
|
|
||||||
## Import Organization
|
|
||||||
|
|
||||||
**Order:**
|
|
||||||
1. Vue framework imports (`import { computed, ref, type Ref } from 'vue'`)
|
|
||||||
2. Library imports (`import { defineStore } from 'pinia'`, `import { format } from 'date-fns'`)
|
|
||||||
3. Local module imports (`import { useFileType } from '../useFileType'`, `import { rpcClient } from '../api/rpc-client'`)
|
|
||||||
4. Types/interfaces (inline in import statements via `type` keyword when needed)
|
|
||||||
5. No blank lines required between groups in practice
|
|
||||||
|
|
||||||
**Path Aliases:**
|
|
||||||
- TypeScript: `@` alias maps to `src/` (configured in `vitest.config.ts` and `tsconfig.json`)
|
|
||||||
- Usage: `import { displayVersion } from '@/utils/version'`
|
|
||||||
- Rust: crate-relative paths (`use crate::module::submodule`) and external crate paths
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
- TypeScript: explicit try-catch with error type narrowing (e.g., `if (error instanceof Error) { ... }`)
|
|
||||||
- RPC client (`rpc-client.ts`): catches fetch errors, AbortError, and HTTP errors; distinguishes retryable (502, 503) from permanent (401, 403)
|
|
||||||
- Rust: `anyhow::Result<T>` for fallible operations; `?` operator for error propagation; `.context("message")` for adding context
|
|
||||||
- Backend error responses: JSON-RPC format with `error: { code, message, data? }` structure; UI catches and displays via toast system
|
|
||||||
- Network errors: automatic retry with exponential backoff (600ms × (attempt + 1) with jitter); configurable `maxRetries` per call
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
**Framework:** console object for frontend, `tracing` crate for Rust backend
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
- Frontend: `console.warn`, `console.error` used selectively (e.g., `[RPC]` prefixed logs in `rpc-client.ts` for session/CSRF events)
|
|
||||||
- Rust: `tracing::info!`, `tracing::warn!` for structured logging; `println!` avoided in production code
|
|
||||||
- No log levels enforced or documented; pragmatic use based on severity
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
**When to Comment:**
|
|
||||||
- Explain non-obvious retry logic, timeout decisions, CSRF handling (see `rpc-client.ts` lines 138–178 for example)
|
|
||||||
- Clarify why a workaround exists (e.g., "Already on the login page: redirecting = a full reload")
|
|
||||||
- Document integration points with backend RPC methods and their expected response shapes
|
|
||||||
- Avoid redundant comments restating what the code obviously does
|
|
||||||
|
|
||||||
**JSDoc/TSDoc:**
|
|
||||||
- Function parameter types documented inline via TypeScript type annotations (e.g., `ext: Ref<string>`)
|
|
||||||
- Minimal use of explicit JSDoc blocks; type signature is the primary documentation
|
|
||||||
- Comments above exports explain purpose in one sentence (e.g., "// RPC Client for connecting to Archipelago backend")
|
|
||||||
- Optional fields in interfaces documented via property-level comments (e.g., `/** Abort the call from the outside … */`)
|
|
||||||
|
|
||||||
## Function Design
|
|
||||||
|
|
||||||
**Size:** Functions are typically 5–50 lines; error-handling paths in `callInner<T>` (`rpc-client.ts`) stretch to 120 lines but remain single-responsibility (retry logic + error classification)
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- Prefer object parameters for 3+ arguments (e.g., `RPCOptions` object over separate `method, params, timeout`)
|
|
||||||
- Vue composables accept `Ref<T>` types to maintain reactivity (e.g., `useFileType(ext: Ref<string>, isDir: Ref<boolean>)`)
|
|
||||||
- Store action functions accept only necessary parameters; broader state via closure
|
|
||||||
|
|
||||||
**Return Values:**
|
|
||||||
- Composables return object with properties (computed values + reactive refs): `{ category, isImage, isAudio, ... }`
|
|
||||||
- Store actions return `void` or the modified state
|
|
||||||
- Utilities return simple values or objects (e.g., `formatSize` returns string, `formatDate` returns string)
|
|
||||||
- Async functions return `Promise<T>` with explicit type parameters (e.g., `async call<T>(options): Promise<T>`)
|
|
||||||
|
|
||||||
## Module Design
|
|
||||||
|
|
||||||
**Exports:**
|
|
||||||
- Composables export a single named function and helper functions: `export function useFileType(...)`, `export function getFileCategory(...)`
|
|
||||||
- Stores export the Pinia store factory: `export const useControllerStore = defineStore(...)`
|
|
||||||
- RPC client exports as singleton instance: `export const rpcClient = new RPCClient()`
|
|
||||||
- Utilities export multiple helpers from the same file (e.g., `formatSize`, `formatDate` from same module)
|
|
||||||
|
|
||||||
**Barrel Files:**
|
|
||||||
- Not observed as a primary pattern; each file self-documents its exports
|
|
||||||
- Imports use direct paths (e.g., `from '../composables/useFileType'`) rather than barrel `index.ts`
|
|
||||||
- Test files import specific utilities directly to minimize test setup complexity
|
|
||||||
|
|
||||||
## Type Safety
|
|
||||||
|
|
||||||
**Vue 3 with TypeScript:**
|
|
||||||
- Components use `<script setup lang="ts">` with `defineProps<{ ... }>()` and `defineEmits<{ ... }>()`
|
|
||||||
- Props explicitly typed as interfaces/objects with required/optional fields marked
|
|
||||||
- Events typed as call signatures (e.g., `'update:modelValue': [value: boolean]`)
|
|
||||||
- Reactive variables typed at declaration: `const isActive = ref<boolean>(false)`, or via inference when obvious
|
|
||||||
|
|
||||||
**Rust:**
|
|
||||||
- Explicit type annotations on public APIs; inference acceptable inside functions
|
|
||||||
- Generic parameters used to encode interface contracts (e.g., `struct DataUrl<'a>`)
|
|
||||||
- Pattern matching to handle enums and `Option<T>` / `Result<T, E>` types safely
|
|
||||||
|
|
||||||
## Component Architecture (Vue)
|
|
||||||
|
|
||||||
**File structure:**
|
|
||||||
- Single-file components with template → script → (optional) style
|
|
||||||
- Props come first, emits second, internal state/computed/methods follow
|
|
||||||
- One component per file (naming matches the file name)
|
|
||||||
- Slots used minimally; prefer explicit prop configuration over slot forwarding
|
|
||||||
|
|
||||||
**Reactivity:**
|
|
||||||
- `ref()` for primitive/object state; `computed()` for derived values
|
|
||||||
- `watch()` used for side effects on ref changes (not extensively shown in samples but implied)
|
|
||||||
- Pinia stores used for global state (authentication, app list, mesh status, etc.)
|
|
||||||
|
|
||||||
## Constants and Enums
|
|
||||||
|
|
||||||
**Pattern:** Constants are module-level `const` with UPPER_SNAKE_CASE names and immutable type annotations:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', ...])
|
|
||||||
const CATEGORY_COLORS: Record<FileCategory, string> = { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Enums:** Type aliases preferred over TypeScript `enum` keyword (e.g., `type FileCategory = 'folder' | 'image' | ...`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Convention analysis: 2026-07-29*
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
# External Integrations
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## APIs & External Services
|
|
||||||
|
|
||||||
**Bitcoin Protocol:**
|
|
||||||
- Bitcoin Core RPC endpoint - Primary blockchain interaction
|
|
||||||
- SDK/Client: `bitcoin` crate (v0.32.5), `reqwest` HTTP client
|
|
||||||
- Endpoint: `http://127.0.0.1:8332/` (configurable)
|
|
||||||
- Used for: Transaction broadcasting, UTXO validation, network sync status
|
|
||||||
- Auth: Basic HTTP auth (hardcoded RPC credentials in containers)
|
|
||||||
|
|
||||||
**Lightning Network:**
|
|
||||||
- Lightning Network Daemon (LND) - Layer 2 payments
|
|
||||||
- SDK/Client: Native REST API (`reqwest` + `serde_json`)
|
|
||||||
- REST Endpoint: `http://localhost:8080/` (container network)
|
|
||||||
- gRPC Endpoint: `http://localhost:10009/` (not currently used by backend)
|
|
||||||
- P2P Port: 9735
|
|
||||||
- Used for: Channel management, payment invoicing, routing
|
|
||||||
- Auth: Macaroon-based authentication (stored in `lnd-data:/root/.lnd`)
|
|
||||||
- Proxied: `/proxy/lnd/` → backend RPC auth + CORS handling
|
|
||||||
|
|
||||||
**Nostr Protocol:**
|
|
||||||
- Nostr Relays - Node discovery and encrypted messaging
|
|
||||||
- SDK/Client: `nostr-sdk` crate (v0.44, with NIP-04 and NIP-44 support)
|
|
||||||
- Usage: Optional, opt-in via `NOSTR_DISCOVERY_ENABLED` config
|
|
||||||
- Relays: Configurable via comma-separated `NOSTR_RELAYS` env var
|
|
||||||
- Transport: SOCKS5 Tor proxy optional via `NOSTR_TOR_PROXY` config
|
|
||||||
- Features: Ed25519 node identity publishing, encrypted peer handshake
|
|
||||||
- Related files: `core/archipelago/src/nostr_relays.rs`, `nostr_discovery.rs`, `nostr_handshake.rs`
|
|
||||||
|
|
||||||
**Mesh Networking:**
|
|
||||||
- Reticulum Protocol (RNS v1.3.5) - Local mesh radio coordination
|
|
||||||
- Daemon: `reticulum-daemon/` (Python daemon, RNS 1.3.5 + LXMF 1.0.1)
|
|
||||||
- Interface: Supervised as managed container
|
|
||||||
- LoRa Radio: Serial2 communication over USB (Meshtastic-compatible radios)
|
|
||||||
- P2P Discovery: mDNS (multicast DNS via `mdns-sd` crate)
|
|
||||||
- Mesh Ports: IPv6 dual-stack listeners (port mirroring to containers)
|
|
||||||
- Related files: `core/archipelago/src/mesh/`, `core/archipelago/src/mesh_ports.rs`
|
|
||||||
|
|
||||||
**Tor (Optional):**
|
|
||||||
- Tor SOCKS5 Proxy - Anonymous network routing
|
|
||||||
- Endpoint: `socks5h://127.0.0.1:9050` (default, configurable)
|
|
||||||
- Used for: Nostr relay connections (when routed through Tor)
|
|
||||||
- Client: `reqwest` with SOCKS feature enabled
|
|
||||||
- Config key: `nostr_tor_proxy`
|
|
||||||
|
|
||||||
**Fedimint:**
|
|
||||||
- Federated Custody Chaumian Mint - Alternative payment layer
|
|
||||||
- SDK/Client: JSON-RPC API (`reqwest` + `serde_json`)
|
|
||||||
- Endpoint: `http://localhost:8174/` (container network)
|
|
||||||
- P2P Port: 8173
|
|
||||||
- UI Port: 8175 (guardian management)
|
|
||||||
- Used for: Custody alternatives, blind signatures
|
|
||||||
- Related files: `core/archipelago/src/api/rpc/fedimint.rs`
|
|
||||||
|
|
||||||
**Decentralized Web Node (DWN):**
|
|
||||||
- DWN Health Check - Decentralized identity messaging
|
|
||||||
- Endpoint: `http://127.0.0.1:3100/health`
|
|
||||||
- Purpose: Node provisioning verification
|
|
||||||
- Related files: `core/archipelago/src/constants.rs`
|
|
||||||
|
|
||||||
## Data Storage
|
|
||||||
|
|
||||||
**Databases:**
|
|
||||||
- **Application Databases (optional, app-managed):**
|
|
||||||
- PostgreSQL: Immich, IndeedHub, Penpot, Endurain, Nextcloud
|
|
||||||
- MySQL/MariaDB: Mempool, Nextcloud
|
|
||||||
- Redis/Valkey: Immich, IndeedHub, Penpot (caching/sessions)
|
|
||||||
- SQLite: Optional local state (Cargo.toml comments out `sqlx`)
|
|
||||||
|
|
||||||
Connection: Via container network (not directly accessible from backend)
|
|
||||||
|
|
||||||
- **Key-Value Stores:**
|
|
||||||
- In-memory cache: Tokio sync primitives (Arc<DashMap>, Mutex)
|
|
||||||
- Persistent app state: User credentials, device tokens, manifest cache stored in `$DATA_DIR`
|
|
||||||
|
|
||||||
**File Storage:**
|
|
||||||
- Local filesystem only
|
|
||||||
- User data directory: `$ARCHIPELAGO_DATA_DIR` (default `/var/lib/archipelago/`)
|
|
||||||
- Subdirectories: `apps/`, `secrets/`, `backups/`, `content/`, `catalog/`
|
|
||||||
- App-specific: `/var/lib/archipelago/<app>/` (mounted into containers)
|
|
||||||
- Content sharing: Peer-to-peer via HTTP Range requests (see `content_server.rs`)
|
|
||||||
|
|
||||||
**Caching:**
|
|
||||||
- No external cache service
|
|
||||||
- In-app caching: Tokio-spawned tasks, Arc<DashMap> for concurrent access
|
|
||||||
- Browser caching: Workbox service worker (5-min API cache, 30-day asset cache, 1-year font cache)
|
|
||||||
|
|
||||||
## Authentication & Identity
|
|
||||||
|
|
||||||
**Auth Provider:**
|
|
||||||
- Custom JWT-based (node-local)
|
|
||||||
- Implementation: Ed25519 signing (key in `credentials/` directory)
|
|
||||||
- Session tokens: Stored browser-side via cookies (CSRF token middleware)
|
|
||||||
- Related files: `core/archipelago/src/auth.rs`, `core/archipelago/src/identity_manager.rs`
|
|
||||||
|
|
||||||
**BIP-39 Mnemonic Seed:**
|
|
||||||
- Seed generation: `bip39` crate (v2.1.0)
|
|
||||||
- HD key derivation: `bitcoin` crate (v0.32.5) with BIP-32
|
|
||||||
- Signing: Ed25519 for identity, ECDSA for Bitcoin transactions
|
|
||||||
- Related files: `core/archipelago/src/seed.rs`
|
|
||||||
|
|
||||||
**Identity (Decentralized):**
|
|
||||||
- Nostr npub (from Ed25519 keys)
|
|
||||||
- DID (Decentralized Identifier) via did:dht (BitTorrent DHT)
|
|
||||||
- Related files: `core/archipelago/src/identity.rs`, `core/archipelago/src/nostr_discovery.rs`
|
|
||||||
|
|
||||||
**2FA:**
|
|
||||||
- TOTP (Time-based One-Time Password)
|
|
||||||
- Library: `totp-rs` (v5.7, with otpauth and gen_secret)
|
|
||||||
- QR generation: `qrcode` crate (v0.14)
|
|
||||||
- Encrypted storage: Argon2-derived key + ChaCha20-Poly1305
|
|
||||||
- Related files: `core/archipelago/src/totp.rs`
|
|
||||||
|
|
||||||
## Monitoring & Observability
|
|
||||||
|
|
||||||
**Error Tracking:**
|
|
||||||
- Not integrated (logging only)
|
|
||||||
|
|
||||||
**Logs:**
|
|
||||||
- Approach: Structured logging via `tracing` crate
|
|
||||||
- Output: stdout (configurable level via `RUST_LOG` or config file)
|
|
||||||
- Subscriber: `tracing-subscriber` with `env-filter`
|
|
||||||
- Related files: `core/archipelago/src/monitoring.rs`, `core/archipelago/src/health_monitor.rs`
|
|
||||||
|
|
||||||
**Health Monitoring:**
|
|
||||||
- Health checks: Container liveness probes (Docker/Podman healthcheck)
|
|
||||||
- System metrics: Disk space, memory, container startup tiers
|
|
||||||
- Related files: `core/archipelago/src/health_monitor.rs`
|
|
||||||
|
|
||||||
## CI/CD & Deployment
|
|
||||||
|
|
||||||
**Hosting:**
|
|
||||||
- Bare metal (Debian Linux) with Podman rootless containers
|
|
||||||
- Fleet nodes: .198, .228, .116, x250-dev, Framework PT (various test/dev targets)
|
|
||||||
|
|
||||||
**CI Pipeline:**
|
|
||||||
- Git-based CI: Gitea (self-hosted at `.160:3000` and `.168:3000`)
|
|
||||||
- Push accounts: `gitea-ai` (for protected main branch)
|
|
||||||
- Build pipeline: Local `cargo build`/`npm run build` (not centralized CI/CD service)
|
|
||||||
- Release: Manual versioning + signed OTA manifests
|
|
||||||
- Docker builds: Local `docker-compose` or Dockerfile.web/backend
|
|
||||||
|
|
||||||
**Deployment:**
|
|
||||||
- Container orchestration: Podman with Quadlet systemd units (Phase 3+)
|
|
||||||
- Legacy fallback: Direct `podman create + systemctl start`
|
|
||||||
- OTA (Over-The-Air): Signed manifest-driven updates (v1.7+)
|
|
||||||
- Sideload: Binary + tarball to `/usr/local/bin/archipelago` and `/opt/archipelago/`
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
**Required env vars:**
|
|
||||||
- `ARCHIPELAGO_DATA_DIR` - Local state directory (default: platform-specific)
|
|
||||||
- `ARCHIPELAGO_LOG_LEVEL` - Logging verbosity (default: `info`)
|
|
||||||
- `CONTAINER_RUNTIME` - `podman` or `docker` (auto-detect by default)
|
|
||||||
- `NOSTR_DISCOVERY_ENABLED` - Enable node publishing to Nostr relays (false by default)
|
|
||||||
- `NOSTR_RELAYS` - Comma-separated relay URLs (if discovery enabled)
|
|
||||||
- `NOSTR_TOR_PROXY` - SOCKS5 proxy address (optional, routes Nostr through Tor)
|
|
||||||
- `VITE_AIUI_URL` - AIUI chat interface URL (frontend, optional)
|
|
||||||
- `BACKEND_URL` - Backend target for dev server (frontend, default: `http://localhost:5959`)
|
|
||||||
|
|
||||||
**Secrets location:**
|
|
||||||
- At-rest: `$DATA_DIR/credentials/` (user.json with encrypted TOTP, session keys)
|
|
||||||
- In-container: Mounted as read-only volumes, never logged
|
|
||||||
- No `.env` file in production (config-driven)
|
|
||||||
|
|
||||||
## Webhooks & Callbacks
|
|
||||||
|
|
||||||
**Incoming:**
|
|
||||||
- Marketplace callbacks - App catalog updates from registry
|
|
||||||
- Peer discovery webhooks (via Nostr relays)
|
|
||||||
- Related files: `core/archipelago/src/webhooks.rs`
|
|
||||||
|
|
||||||
**Outgoing:**
|
|
||||||
- Device token push notifications (not yet implemented)
|
|
||||||
- App install/uninstall event notifications (framework-pt integration)
|
|
||||||
- Related files: `core/archipelago/src/device_tokens.rs`
|
|
||||||
|
|
||||||
## Container-Based Services (Orchestrated by Archipelago)
|
|
||||||
|
|
||||||
**Bitcoin Stack:**
|
|
||||||
- Bitcoin Core (lncm/bitcoind:v27.0 or knots variant)
|
|
||||||
- ElectrumX (via separate image)
|
|
||||||
- Electrs (alternative to ElectrumX)
|
|
||||||
|
|
||||||
**Lightning/Payments:**
|
|
||||||
- LND (lightninglabs/lnd:v0.17.4-beta+)
|
|
||||||
- BTCPay Server (btcpayserver:1.13.5+)
|
|
||||||
- Fedimint (fedimint/fedimintd:v0.10.0+)
|
|
||||||
|
|
||||||
**Media & Content:**
|
|
||||||
- Immich (self-hosted photo/media library)
|
|
||||||
- Nextcloud (cloud storage)
|
|
||||||
- OnlyOffice (document server)
|
|
||||||
- Penpot (design tool)
|
|
||||||
- SearXNG (search engine)
|
|
||||||
- FileBrowser (file management UI)
|
|
||||||
|
|
||||||
**Infrastructure:**
|
|
||||||
- nginx (reverse proxy, CORS, rate limiting)
|
|
||||||
- Home Assistant (home automation hub)
|
|
||||||
- Grafana (metrics dashboard)
|
|
||||||
- ThunderHub (Lightning node UI)
|
|
||||||
- Mempool Explorer (blockchain monitor)
|
|
||||||
- Endurain (fitness tracking)
|
|
||||||
- Morphos (file converter)
|
|
||||||
- IndeedHub (job board)
|
|
||||||
- Pine (voice assistant)
|
|
||||||
|
|
||||||
## Integration Points (API Contracts)
|
|
||||||
|
|
||||||
**Backend ↔ Frontend (gRPC-style RPC):**
|
|
||||||
- Endpoint: `/rpc/v1/*` (HTTP/REST)
|
|
||||||
- Related files: `core/archipelago/src/api/rpc/` (all RPC handlers)
|
|
||||||
- Major modules: `auth.rs`, `bitcoin.rs`, `lnd/`, `container.rs`, `marketplace.rs`, `mesh/`
|
|
||||||
|
|
||||||
**Frontend ↔ App Iframes:**
|
|
||||||
- Endpoint: `/app/<app-name>/*` (proxied to container)
|
|
||||||
- Sandbox: Cross-origin iframe isolation
|
|
||||||
|
|
||||||
**Mesh Node ↔ Reticulum:**
|
|
||||||
- Serial: USB LoRa radio (Meshtastic-compatible)
|
|
||||||
- Protocol: Binary Meshcore format
|
|
||||||
- Related files: `core/archipelago/src/mesh/`
|
|
||||||
|
|
||||||
**Catalog ↔ App Registry:**
|
|
||||||
- Endpoint: `/api/app-catalog` (cached from registry)
|
|
||||||
- Format: Signed YAML manifest + SHA256 verification
|
|
||||||
- Related files: `core/archipelago/src/marketplace.rs`, `app_catalog/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Integration audit: 2026-07-29*
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
# Technology Stack
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## Languages
|
|
||||||
|
|
||||||
**Primary:**
|
|
||||||
- Rust 2021 edition - Backend server (Archipelago daemon, container orchestrator)
|
|
||||||
- TypeScript 5.9 - Frontend (Vue components, application code)
|
|
||||||
- Vue 3 (TypeScript) - UI framework and reactive components
|
|
||||||
- Python 3.13 - Reticulum mesh daemon (RNS/LXMF protocols)
|
|
||||||
|
|
||||||
**Secondary:**
|
|
||||||
- JavaScript - Build scripts, mock backend (`neode-ui/mock-backend.js`), test utilities
|
|
||||||
- YAML - Configuration (docker-compose, app manifests)
|
|
||||||
- Shell - Build scripts, deployment helpers
|
|
||||||
|
|
||||||
## Runtime
|
|
||||||
|
|
||||||
**Environment:**
|
|
||||||
- Rust (1.70+) - Native compiled binaries
|
|
||||||
- Node.js 22 (Alpine-based containers) - Frontend dev/build, mock backend
|
|
||||||
- Python 3.13 - Reticulum daemon runtime
|
|
||||||
- Tokio async runtime (v1, full features) - Async server runtime
|
|
||||||
|
|
||||||
**Package Manager:**
|
|
||||||
- npm (v10+) - JavaScript dependencies
|
|
||||||
- Cargo (v1.70+) - Rust dependencies
|
|
||||||
- pip - Python dependencies (Reticulum stack)
|
|
||||||
- Lockfiles: `neode-ui/package-lock.json`, `core/Cargo.lock`
|
|
||||||
|
|
||||||
## Frameworks
|
|
||||||
|
|
||||||
**Core:**
|
|
||||||
- Tokio (v1) - Async Rust runtime, full features (networking, signals, sync primitives)
|
|
||||||
- Vue 3 (v3.5) - Progressive web framework with TypeScript support
|
|
||||||
- Hyper (v0.14) - HTTP/1 server framework with WebSocket support
|
|
||||||
- Reticulum (v1.3.5) - Mesh networking protocol stack
|
|
||||||
- LXMF (v1.0.1) - Lightweight message format protocol
|
|
||||||
|
|
||||||
**HTTP & WebSocket:**
|
|
||||||
- Hyper v0.14 (HTTP/1, full features) - Core HTTP server
|
|
||||||
- Hyper-util v0.1 - HTTP utilities
|
|
||||||
- Tower v0.5 - Middleware and service composing
|
|
||||||
- Tower-http v0.6 - CORS, tracing middleware
|
|
||||||
- Hyper-ws-listener v0.3 - WebSocket upgrade handler
|
|
||||||
- Tokio-tungstenite v0.20 - WebSocket client/server implementation
|
|
||||||
- Reqwest v0.11 - HTTP client (with rustls-tls, SOCKS proxy support, JSON)
|
|
||||||
|
|
||||||
**Frontend Build:**
|
|
||||||
- Vite v7.2 - Build tool and dev server
|
|
||||||
- Vue-tsc v3.1 - TypeScript compilation for Vue
|
|
||||||
- Tailwind CSS v3.4 - Utility-first CSS framework
|
|
||||||
- Autoprefixer v10.4 - CSS vendor prefixes
|
|
||||||
- PostCSS v8.5 - CSS processing
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Vitest v3.1 - Unit test runner (Vite-native)
|
|
||||||
- Playwright v1.58 - E2E testing (browser automation)
|
|
||||||
- @vue/test-utils v2.4 - Vue component testing
|
|
||||||
- JSDOM v25 - DOM implementation for tests
|
|
||||||
- Tokio-test v0.4 - Async Rust test utilities
|
|
||||||
|
|
||||||
**PWA:**
|
|
||||||
- Vite-plugin-pwa v1.2 - Progressive Web App support
|
|
||||||
- Workbox integration - Service worker caching strategies
|
|
||||||
|
|
||||||
## Key Dependencies
|
|
||||||
|
|
||||||
**Critical:**
|
|
||||||
- bitcoin v0.32.5 - Bitcoin library (with rand-std feature for BIP-39/BIP-32)
|
|
||||||
- bip39 v2.1.0 - Mnemonic seed generation
|
|
||||||
- nostr-sdk v0.44 - Nostr protocol (NIP-04, NIP-44 encrypted messaging)
|
|
||||||
- mainline v2 - BitTorrent DHT (did:dht decentralized identity)
|
|
||||||
- reticulum v1.3.5 - Mesh networking protocol
|
|
||||||
- lxmf v1.0.1 - Lightweight message format
|
|
||||||
|
|
||||||
**Cryptography:**
|
|
||||||
- ed25519-dalek v2.2 - Ed25519 digital signatures (with rand_core)
|
|
||||||
- curve25519-dalek v4.1 - X25519 elliptic curve (key agreement)
|
|
||||||
- blake3 v1 - BLAKE3 hash function
|
|
||||||
- bcrypt v0.15 - Password hashing
|
|
||||||
- sha2 v0.10 - SHA-256 hashing
|
|
||||||
- hmac v0.12 - HMAC authentication
|
|
||||||
- argon2 v0.5 - Argon2 password hashing
|
|
||||||
- chacha20poly1305 v0.10 - AEAD encryption
|
|
||||||
- zeroize v1.8 - Secure memory wiping
|
|
||||||
|
|
||||||
**Authentication & Identity:**
|
|
||||||
- uuid v1.0 - UUID generation (v4)
|
|
||||||
- totp-rs v5.7 - TOTP 2FA (with otpauth, gen_secret)
|
|
||||||
- qrcode v0.14 - QR code generation (server-side)
|
|
||||||
|
|
||||||
**Data Serialization:**
|
|
||||||
- serde v1.0 - Serialization framework (with derive)
|
|
||||||
- serde_json v1.0 - JSON codec
|
|
||||||
- serde_yaml v0.9 - YAML codec
|
|
||||||
- ciborium v0.2 - CBOR encoding/decoding
|
|
||||||
- serde_bytes v0.11 - Efficient byte serialization
|
|
||||||
- toml v0.8 - TOML config parsing
|
|
||||||
|
|
||||||
**Networking & Mesh:**
|
|
||||||
- mdns-sd v0.18 - mDNS service discovery
|
|
||||||
- serial2-tokio v0.1 - Serial port communication (LoRa radios over USB)
|
|
||||||
- socket2 v0.5 - Low-level socket options (IPv6_V6ONLY for dual-stack)
|
|
||||||
- libc v0.2 - Process group signaling
|
|
||||||
|
|
||||||
**Compression & Archives:**
|
|
||||||
- tar v0.4 - TAR archive creation
|
|
||||||
- flate2 v1.0 - gzip compression
|
|
||||||
- zip v2.0 - ZIP archive handling (LoRa firmware flashing)
|
|
||||||
- reed-solomon-erasure v6.0 - Erasure coding (Phase 2 mesh transport)
|
|
||||||
- hkdf v0.12 - HKDF key derivation (Phase 3 encrypted mesh)
|
|
||||||
|
|
||||||
**Utilities:**
|
|
||||||
- anyhow v1.0 - Error handling
|
|
||||||
- thiserror v1.0 - Error types with derive macros
|
|
||||||
- tracing v0.1 - Structured logging
|
|
||||||
- tracing-subscriber v0.3 - Log filtering and formatting
|
|
||||||
- regex v1.10 - Pattern matching
|
|
||||||
- chrono v0.4 - Date/time handling
|
|
||||||
- hex v0.4 - Hex encoding/decoding
|
|
||||||
- bs58 v0.5 - Base58 encoding (Bitcoin addresses)
|
|
||||||
- base64 v0.21 - Base64 encoding
|
|
||||||
- zbase32 v0.1 - Z-base-32 encoding (DHT)
|
|
||||||
- data-encoding v2.6 - Multiple encoding schemes
|
|
||||||
- bytes v1 - Efficient byte buffer
|
|
||||||
- futures-util v0.3 - Async utilities
|
|
||||||
- http-body-util v0.1 - HTTP body utilities
|
|
||||||
- http-body v1.0 - HTTP body abstractions
|
|
||||||
- indexmap v2.0 - Ordered maps
|
|
||||||
- async-trait v0.1 - Async trait methods
|
|
||||||
- sd-notify v0.4 - Systemd watchdog notification
|
|
||||||
|
|
||||||
**Infrastructure (Optional):**
|
|
||||||
- iroh v1 (optional, feature-gated) - QUIC-based peer swarm engine (Phase 2)
|
|
||||||
- iroh-blobs v0.103 (optional, feature-gated) - Content addressable storage provider
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
**Environment:**
|
|
||||||
- Config file: `config/archipelago.toml` or `$DATA_DIR/config.yml`
|
|
||||||
- Runtime options: Docker/Podman selection, dev/prod modes, FIPS anchor selection, Nostr discovery
|
|
||||||
- Key env vars: `ARCHIPELAGO_DATA_DIR`, `ARCHIPELAGO_LOG_LEVEL`, `CONTAINER_RUNTIME`, `NOSTR_DISCOVERY_ENABLED`, `NOSTR_RELAYS`, `NOSTR_TOR_PROXY`
|
|
||||||
|
|
||||||
**Build:**
|
|
||||||
- Cargo workspace at `core/` (5 members: archipelago, container, openwrt, performance, security)
|
|
||||||
- Release profile: opt-level 3
|
|
||||||
- Dev profile: opt-level 0
|
|
||||||
- Test profile: opt-level 3
|
|
||||||
- Cross-compilation: aarch64-unknown-linux-gnu via `.cargo/config.toml`
|
|
||||||
|
|
||||||
**Frontend Build:**
|
|
||||||
- Vite config: `neode-ui/vite.config.ts` (development port 8100, production build to `../web/dist/neode-ui`)
|
|
||||||
- TypeScript config: `neode-ui/tsconfig.json`
|
|
||||||
- Module path alias: `@` → `src/`
|
|
||||||
|
|
||||||
## Platform Requirements
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
- Rust 1.70+ with Cargo
|
|
||||||
- Node.js 22+ with npm
|
|
||||||
- Python 3.13+ (for Reticulum daemon)
|
|
||||||
- Docker or Podman (for local app testing)
|
|
||||||
- rustup target: `aarch64-unknown-linux-gnu` (for ARM64 cross-compilation)
|
|
||||||
- gcc-aarch64-linux-gnu (for cross-compilation toolchain on Linux hosts)
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
- Deployment target: Debian/Alpine Linux (rootless Podman)
|
|
||||||
- Binary output: `/usr/local/bin/archipelago` (sideloaded or via Quadlet systemd units)
|
|
||||||
- Frontend served: nginx with Vite-built SPA (PWA manifest, service worker, CORS proxies)
|
|
||||||
- Database support: Optional (SQLx with SQLite driver available but commented out)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Stack analysis: 2026-07-29*
|
|
||||||
@ -1,434 +0,0 @@
|
|||||||
# Codebase Structure
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
archipelago-repo/
|
|
||||||
├── core/ # Rust workspace root (Cargo.toml at workspace level)
|
|
||||||
│ ├── archipelago/ # Main daemon binary (backend)
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── main.rs # Entry point, startup, background tasks
|
|
||||||
│ │ │ ├── server.rs # HTTP server (Hyper), listener, connection multiplexing
|
|
||||||
│ │ │ ├── state.rs # StateManager + data_model.rs (central state)
|
|
||||||
│ │ │ ├── auth.rs # User auth, password hashing, session management
|
|
||||||
│ │ │ ├── identity.rs # Node identity, Ed25519 keys, Tor address
|
|
||||||
│ │ │ ├── config.rs # Config loading, data directory setup
|
|
||||||
│ │ │ ├── api/ # HTTP API layer
|
|
||||||
│ │ │ │ ├── handler/ # HTTP request dispatch, WebSocket, content proxy
|
|
||||||
│ │ │ │ └── rpc/ # JSON-RPC 2.0 methods (~40 domain modules)
|
|
||||||
│ │ │ │ ├── auth.rs # auth.login, auth.setup, etc.
|
|
||||||
│ │ │ │ ├── container.rs # container.install, .list, .start, etc.
|
|
||||||
│ │ │ │ ├── bitcoin.rs # bitcoin.status, bitcoin.send, etc.
|
|
||||||
│ │ │ │ ├── mesh.rs # mesh.* (peer discovery, LoRa, federation)
|
|
||||||
│ │ │ │ ├── wallet.rs # wallet.* (lightning, Bitcoin)
|
|
||||||
│ │ │ │ └── [20+ other domains]
|
|
||||||
│ │ │ ├── container/ # Container orchestration (Podman)
|
|
||||||
│ │ │ │ ├── prod_orchestrator.rs # Main Podman lifecycle (>250KB)
|
|
||||||
│ │ │ │ ├── boot_reconciler.rs # Periodic manifest sync loop
|
|
||||||
│ │ │ │ ├── docker_packages.rs # Image registry, image verification
|
|
||||||
│ │ │ │ ├── quadlet.rs # Systemd Quadlet generation
|
|
||||||
│ │ │ │ ├── secrets.rs # Secret injection (0600 files)
|
|
||||||
│ │ │ │ ├── lnd.rs # Lightning Network Daemon container setup
|
|
||||||
│ │ │ │ ├── app_catalog.rs # Signed app catalog, manifest overlay
|
|
||||||
│ │ │ │ └── [data managers, registry, image policy]
|
|
||||||
│ │ │ ├── bootstrap.rs # Post-startup tasks (systemd units, audio stack, gamepad)
|
|
||||||
│ │ │ ├── crash_recovery.rs # Container recovery after crash, PID marker
|
|
||||||
│ │ │ ├── health_monitor.rs # Periodic app health checks, restart
|
|
||||||
│ │ │ ├── mesh.rs # Mesh P2P listener, sender, LoRa radio control
|
|
||||||
│ │ │ ├── federation.rs # Federation (DNS-SD, HTTP API)
|
|
||||||
│ │ │ ├── fips/ # FIPS anchor (Tor bridge to peer)
|
|
||||||
│ │ │ ├── bitcoin_rpc.rs # Bitcoin Core RPC client calls
|
|
||||||
│ │ │ ├── bitcoin_status.rs # Bitcoin sync status polling
|
|
||||||
│ │ │ ├── content_server.rs # Content-addressed blob server (CAP tokens)
|
|
||||||
│ │ │ ├── blobs.rs # BlobStore (hash→file mapping, encryption)
|
|
||||||
│ │ │ ├── wallet.rs # Lightning + Bitcoin wallet logic
|
|
||||||
│ │ │ ├── identity_manager.rs # Seed derivation, key rotation
|
|
||||||
│ │ │ ├── marketplace.rs # Marketplace transaction logic
|
|
||||||
│ │ │ ├── transport.rs # Transport selection (Mesh/FIPS/Tor routing)
|
|
||||||
│ │ │ ├── update.rs # OTA update apply, verification, rollback
|
|
||||||
│ │ │ ├── session.rs # Session store (SQLite or in-memory)
|
|
||||||
│ │ │ ├── rate_limit.rs # Rate limiter (per-IP, per-endpoint, per-user)
|
|
||||||
│ │ │ ├── monitoring.rs # Metrics collection (app count, memory, etc.)
|
|
||||||
│ │ │ ├── data_model.rs # State struct tree (serde Serialize/Deserialize)
|
|
||||||
│ │ │ ├── constants.rs # Global constants (version, defaults)
|
|
||||||
│ │ │ ├── peer*.rs, webhook*.rs, nostr*.rs, vpn.rs, etc.
|
|
||||||
│ │ │ └── seed.rs # Seed storage, backup QR generation
|
|
||||||
│ │ ├── Cargo.toml # Dependencies
|
|
||||||
│ │ └── tests/ # Unit/integration tests
|
|
||||||
│ │
|
|
||||||
│ ├── container/ # Container management library (OCI types, Podman)
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── manifest.rs # Manifest struct (YAML parsing)
|
|
||||||
│ │ │ ├── runtime.rs # Podman CLI calls (create, start, stop, logs)
|
|
||||||
│ │ │ ├── podman_client.rs # Podman socket API client
|
|
||||||
│ │ │ ├── image_verify.rs # Image signature verification (Cosign)
|
|
||||||
│ │ │ └── port_manager.rs # Port allocation, conflict detection
|
|
||||||
│ │ └── Cargo.toml
|
|
||||||
│ │
|
|
||||||
│ ├── security/ # Secrets management library
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── secrets_manager.rs # Secret encryption/decryption (ChaCha20)
|
|
||||||
│ │ │ └── vault.rs # Vault storage, rotation
|
|
||||||
│ │ └── Cargo.toml
|
|
||||||
│ │
|
|
||||||
│ ├── performance/ # Performance monitoring library
|
|
||||||
│ ├── openwrt/ # OpenWrt device integration
|
|
||||||
│ ├── Cargo.toml # Workspace manifest (members: archipelago, container, security, etc.)
|
|
||||||
│ └── Cargo.lock # Locked dependency versions
|
|
||||||
│
|
|
||||||
├── neode-ui/ # Frontend (Vue 3, TypeScript)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── main.ts # Vue app entry point, Router setup, WebSocket init
|
|
||||||
│ │ ├── App.vue # Root component (layout, nav)
|
|
||||||
│ │ ├── router/
|
|
||||||
│ │ │ └── index.ts # Vue Router config (routes, guards)
|
|
||||||
│ │ ├── views/ # Page-level components (one per route)
|
|
||||||
│ │ │ ├── Home.vue # Dashboard
|
|
||||||
│ │ │ ├── Apps.vue # App browser + installer
|
|
||||||
│ │ │ ├── AppDetails.vue # Single app detail + logs
|
|
||||||
│ │ │ ├── AppSession.vue # Iframe container for app content
|
|
||||||
│ │ │ ├── Cloud.vue # File browser (WebDAV/DWN)
|
|
||||||
│ │ │ ├── Mesh.vue # Mesh map, contacts, messages
|
|
||||||
│ │ │ ├── Wallet.vue # Lightning + Bitcoin addresses/sends
|
|
||||||
│ │ │ ├── Marketplace.vue # Paid apps, content marketplace
|
|
||||||
│ │ │ ├── Server.vue # Node status, settings, restart
|
|
||||||
│ │ │ ├── Federation.vue # Federation peers, federation apps
|
|
||||||
│ │ │ ├── Onboarding*/ # Multi-step setup flow
|
|
||||||
│ │ │ └── [15+ other pages]
|
|
||||||
│ │ ├── components/ # Reusable UI components
|
|
||||||
│ │ │ ├── AppCard.vue # App listing card
|
|
||||||
│ │ │ ├── AppInstaller.vue # Install modal
|
|
||||||
│ │ │ ├── Modal.vue # Generic modal (teleported)
|
|
||||||
│ │ │ ├── Toast.vue # Notification toast
|
|
||||||
│ │ │ ├── MeshGraph.vue # Mesh topology graph (D3)
|
|
||||||
│ │ │ ├── MapView.vue # Mesh map (Leaflet)
|
|
||||||
│ │ │ ├── QrScanner.vue # QR code input
|
|
||||||
│ │ │ └── [30+ other components]
|
|
||||||
│ │ ├── composables/ # Logic hooks (Vue composition API)
|
|
||||||
│ │ │ ├── useAuth.ts # Login/logout logic
|
|
||||||
│ │ │ ├── useAppStore.ts # Access app Pinia store
|
|
||||||
│ │ │ ├── useRpc.ts # Make RPC calls
|
|
||||||
│ │ │ ├── useWebSocket.ts # WebSocket connection management
|
|
||||||
│ │ │ ├── useOnboarding.ts # Onboarding flow state
|
|
||||||
│ │ │ ├── useControllerNav.ts # Gamepad controller navigation
|
|
||||||
│ │ │ └── [20+ other composables]
|
|
||||||
│ │ ├── stores/ # Pinia state management (reactive stores)
|
|
||||||
│ │ │ ├── appStore.ts # Apps list, install state
|
|
||||||
│ │ │ ├── walletStore.ts # Lightning/Bitcoin addresses, balance
|
|
||||||
│ │ │ ├── meshStore.ts # Mesh peers, messages
|
|
||||||
│ │ │ ├── settingsStore.ts # User settings, theme, language
|
|
||||||
│ │ │ ├── userStore.ts # Current user identity
|
|
||||||
│ │ │ └── [other stores]
|
|
||||||
│ │ ├── api/
|
|
||||||
│ │ │ └── rpc-client.ts # RPC client library (request, WebSocket, reconnect)
|
|
||||||
│ │ ├── services/ # Business logic (not Vue-dependent)
|
|
||||||
│ │ │ ├── qrScanner.ts # QR scanner initialization
|
|
||||||
│ │ │ └── [other services]
|
|
||||||
│ │ ├── utils/ # Utility functions
|
|
||||||
│ │ │ ├── format.ts # Date, number, currency formatting
|
|
||||||
│ │ │ ├── validate.ts # Input validation (emails, addresses)
|
|
||||||
│ │ │ ├── crypto.ts # Client-side crypto (BIP39, etc.)
|
|
||||||
│ │ │ └── [helpers]
|
|
||||||
│ │ ├── types/ # TypeScript type definitions
|
|
||||||
│ │ │ ├── index.ts # Export all types
|
|
||||||
│ │ │ └── [domain-specific types]
|
|
||||||
│ │ ├── i18n.ts # Internationalization config
|
|
||||||
│ │ ├── locales/ # Translation files
|
|
||||||
│ │ │ ├── en.json # English
|
|
||||||
│ │ │ ├── es.json # Spanish
|
|
||||||
│ │ │ └── [other languages]
|
|
||||||
│ │ ├── assets/ # Static assets
|
|
||||||
│ │ │ └── icon/ # App icons, favicons
|
|
||||||
│ │ ├── style.css # Global CSS (Tailwind + custom)
|
|
||||||
│ │ ├── data/ # Static data (country lists, etc.)
|
|
||||||
│ │ └── e2e/ # Playwright E2E tests
|
|
||||||
│ │ ├── intro-experience.spec.ts
|
|
||||||
│ │ └── app-launch.spec.ts
|
|
||||||
│ │
|
|
||||||
│ ├── public/ # Static web root
|
|
||||||
│ │ ├── index.html # HTML entry point
|
|
||||||
│ │ ├── favicon.ico # Browser tab icon
|
|
||||||
│ │ ├── manifest.json # PWA manifest
|
|
||||||
│ │ └── catalog.json # App catalog (copied from app-catalog/catalog.json)
|
|
||||||
│ │
|
|
||||||
│ ├── package.json # Frontend dependencies + build scripts
|
|
||||||
│ ├── tsconfig.json # TypeScript config
|
|
||||||
│ ├── vite.config.ts # Vite build config
|
|
||||||
│ ├── vitest.config.ts # Vitest test runner config
|
|
||||||
│ ├── tailwind.config.js # Tailwind CSS config
|
|
||||||
│ ├── mock-backend.js # Dev mock server (for `npm run dev:mock`)
|
|
||||||
│ └── [other build configs]
|
|
||||||
│
|
|
||||||
├── apps/ # Containerized applications (app manifests + build scripts)
|
|
||||||
│ ├── bitcoin-core/ # Bitcoin Core container
|
|
||||||
│ │ ├── manifest.yml # Archipelago manifest (interface, ports, secrets, health)
|
|
||||||
│ │ ├── Dockerfile # OCI image definition
|
|
||||||
│ │ ├── bitcoin.conf.template # Config template (secrets injected at runtime)
|
|
||||||
│ │ └── start.sh # Container entrypoint
|
|
||||||
│ │
|
|
||||||
│ ├── lightning-stack/ # Lightning Network stack (LND)
|
|
||||||
│ ├── lnd/ # LND daemon
|
|
||||||
│ ├── immich/ # Photo backup app
|
|
||||||
│ ├── nextcloud/ # Cloud storage
|
|
||||||
│ ├── electrumx/ # Bitcoin block explorer index
|
|
||||||
│ ├── router/ # Mesh router app
|
|
||||||
│ ├── pine/ # Voice assistant (whisper + piper + nginx)
|
|
||||||
│ ├── vaultwarden/ # Password manager
|
|
||||||
│ ├── [40+ other apps]
|
|
||||||
│ ├── QUICKSTART.md # App development guide
|
|
||||||
│ ├── PORTS.md # Port allocation reference
|
|
||||||
│ └── build.sh # App build script (all apps)
|
|
||||||
│
|
|
||||||
├── web/ # Built frontend output
|
|
||||||
│ └── dist/
|
|
||||||
│ └── neode-ui/ # `npm run build` output (served by nginx)
|
|
||||||
│ ├── index.html
|
|
||||||
│ ├── [JS bundles]
|
|
||||||
│ └── [static assets]
|
|
||||||
│
|
|
||||||
├── tests/ # Test suite
|
|
||||||
│ ├── lifecycle/
|
|
||||||
│ │ ├── run-gate.sh # Single-node production gate (5 iterations)
|
|
||||||
│ │ └── TESTING.md # Test plan documentation
|
|
||||||
│ ├── e2e/ # End-to-end tests (Playwright)
|
|
||||||
│ └── [other test directories]
|
|
||||||
│
|
|
||||||
├── docs/ # Documentation (user & developer guides)
|
|
||||||
│ ├── PRODUCTION-MASTER-PLAN.md # North star: manifest-driven, registry-based, decentralized
|
|
||||||
│ ├── UNIFIED-TASK-TRACKER.md # Open tasks (fastest-first)
|
|
||||||
│ ├── APP-PACKAGING-MIGRATION-PLAN.md
|
|
||||||
│ ├── registry-manifest-design.md
|
|
||||||
│ ├── multinode-testing-plan.md
|
|
||||||
│ ├── release-workflow.md # OTA + ISO release process
|
|
||||||
│ ├── app-development.md # Guide for app developers
|
|
||||||
│ ├── api-rpc-reference.md # JSON-RPC 2.0 method documentation
|
|
||||||
│ └── [20+ other docs]
|
|
||||||
│
|
|
||||||
├── image-recipe/ # ISO/image build scripts
|
|
||||||
│ ├── build-debian-iso.sh # Builds bootable Debian ISO
|
|
||||||
│ ├── include/ # Root filesystem overlays
|
|
||||||
│ │ └── opt/archipelago/ # Pre-baked config, scripts, systemd units
|
|
||||||
│ └── [other image components]
|
|
||||||
│
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
│ ├── deploy-to-target.sh # Sideload binary to test node (Tailscale SSH + rsync)
|
|
||||||
│ ├── resilience/ # Resilience test scripts
|
|
||||||
│ └── [other scripts]
|
|
||||||
│
|
|
||||||
├── demo/ # Demo deployment (pre-configured node)
|
|
||||||
│ ├── demo-deploy.yml # Docker Compose for vps2 demo
|
|
||||||
│ └── [demo-specific scripts]
|
|
||||||
│
|
|
||||||
├── docker/ # Docker/Podman config
|
|
||||||
│ └── [docker-compose fragments, Dockerfiles]
|
|
||||||
│
|
|
||||||
├── .planning/ # Codebase analysis documents (this is you)
|
|
||||||
│ └── codebase/
|
|
||||||
│ ├── ARCHITECTURE.md
|
|
||||||
│ ├── STRUCTURE.md
|
|
||||||
│ ├── CONVENTIONS.md
|
|
||||||
│ ├── TESTING.md
|
|
||||||
│ ├── STACK.md
|
|
||||||
│ ├── INTEGRATIONS.md
|
|
||||||
│ └── CONCERNS.md
|
|
||||||
│
|
|
||||||
├── .claude/ # Claude Code configuration
|
|
||||||
│ └── skills/ # GSD skills (if any project-specific)
|
|
||||||
│
|
|
||||||
├── .github/ # GitHub Actions CI/CD
|
|
||||||
├── .gitea/ # Gitea CI/CD (local Gitea runner)
|
|
||||||
├── .git/ # Git repository
|
|
||||||
├── .gitignore # Git ignore patterns
|
|
||||||
├── CLAUDE.md # Project instructions (commit rules, invariants, testing)
|
|
||||||
├── Cargo.lock # Locked Rust dependency versions
|
|
||||||
├── CHANGELOG.md # Release notes
|
|
||||||
├── CODE_OF_CONDUCT.md
|
|
||||||
├── CONTRIBUTING.md
|
|
||||||
└── README.md # Project overview
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Purposes
|
|
||||||
|
|
||||||
**core/**
|
|
||||||
- Purpose: Rust backend source and dependencies
|
|
||||||
- Contains: Main daemon binary, container orchestration, RPC handlers, business logic
|
|
||||||
- Key files: `archipelago/src/main.rs` (entry point), `archipelago/Cargo.toml` (deps)
|
|
||||||
|
|
||||||
**neode-ui/**
|
|
||||||
- Purpose: Vue 3 frontend SPA source
|
|
||||||
- Contains: Views, components, stores, translations, tests, build config
|
|
||||||
- Key files: `src/main.ts` (entry point), `vite.config.ts` (build), `package.json` (deps)
|
|
||||||
|
|
||||||
**apps/**
|
|
||||||
- Purpose: Containerized application definitions
|
|
||||||
- Contains: Manifests (YAML), Dockerfiles, configs for ~50 apps (Bitcoin, LND, Immich, etc.)
|
|
||||||
- Key files: `*/manifest.yml` (app definition), `*/Dockerfile` (image build)
|
|
||||||
|
|
||||||
**web/dist/neode-ui/**
|
|
||||||
- Purpose: Production frontend output (built by `npm run build`)
|
|
||||||
- Contains: Bundled JS, HTML, static assets
|
|
||||||
- Key files: `index.html` (entry), JS chunks (hashed filenames)
|
|
||||||
- Note: Served by nginx at `/` on node
|
|
||||||
|
|
||||||
**tests/**
|
|
||||||
- Purpose: Test suite (unit, integration, E2E)
|
|
||||||
- Contains: Lifecycle gate (production exit criterion), E2E specs, test utilities
|
|
||||||
- Key files: `lifecycle/run-gate.sh` (the production gate), `e2e/*.spec.ts` (user flows)
|
|
||||||
|
|
||||||
**docs/**
|
|
||||||
- Purpose: User and developer documentation
|
|
||||||
- Contains: Architecture, design decisions, release process, task tracking
|
|
||||||
- Key files: `PRODUCTION-MASTER-PLAN.md` (north star), `UNIFIED-TASK-TRACKER.md` (open tasks)
|
|
||||||
|
|
||||||
**image-recipe/**
|
|
||||||
- Purpose: ISO/image build scripts
|
|
||||||
- Contains: Debian ISO recipe, root filesystem overlays, bootloader config
|
|
||||||
- Key files: `build-debian-iso.sh` (main build), `include/opt/archipelago/` (pre-baked system config)
|
|
||||||
|
|
||||||
**scripts/**
|
|
||||||
- Purpose: Utility and deployment scripts
|
|
||||||
- Contains: Sideload/deploy to test nodes, resilience testing, CI glue
|
|
||||||
- Key files: `deploy-to-target.sh` (ship binary to remote node)
|
|
||||||
|
|
||||||
## Key File Locations
|
|
||||||
|
|
||||||
**Entry Points:**
|
|
||||||
- Backend daemon: `core/archipelago/src/main.rs` (spawns HTTP server, tasks, orchestrator)
|
|
||||||
- Frontend app: `neode-ui/src/main.ts` (Vue app, router, WebSocket init)
|
|
||||||
- App manifest: `apps/*/manifest.yml` (Archipelago-specific; controls container, secrets, UI)
|
|
||||||
- Production gate: `tests/lifecycle/run-gate.sh` (exit criterion for releases)
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
- Backend config: `core/archipelago/src/config.rs` (data dir, bind port, dev mode flag)
|
|
||||||
- Frontend config: `neode-ui/vite.config.ts` (build settings, env vars)
|
|
||||||
- App registries: `/var/lib/archipelago/registries.json` (user-configured Gitea mirrors)
|
|
||||||
- Secrets location: `/var/lib/archipelago/secrets/` (encrypted ChaCha20 files)
|
|
||||||
|
|
||||||
**Core Logic:**
|
|
||||||
- Container orchestration: `core/archipelago/src/container/prod_orchestrator.rs` (300KB+ main logic)
|
|
||||||
- RPC dispatch: `core/archipelago/src/api/rpc/mod.rs` (method routing, ~200 RPCs)
|
|
||||||
- State management: `core/archipelago/src/state.rs` (StateManager) + `core/archipelago/src/data_model.rs` (struct def)
|
|
||||||
- Frontend stores: `neode-ui/src/stores/` (Pinia stores, reactive state)
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Rust unit tests: Inline in `core/` modules (use `#[test]` and `#[tokio::test]`)
|
|
||||||
- Vitest unit tests: `neode-ui/src/**/__tests__/*.test.ts`
|
|
||||||
- E2E tests: `neode-ui/e2e/*.spec.ts` (Playwright)
|
|
||||||
- Integration tests: `tests/` (shell scripts, node command testing)
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Rust modules: `snake_case.rs` (e.g., `health_monitor.rs`, `crash_recovery.rs`)
|
|
||||||
- Vue components: `PascalCase.vue` (e.g., `AppCard.vue`, `MeshGraph.vue`)
|
|
||||||
- Composables: `use[Name].ts` (e.g., `useAuth.ts`, `useRpc.ts`)
|
|
||||||
- Stores: `[domain]Store.ts` (e.g., `appStore.ts`, `walletStore.ts`)
|
|
||||||
- Tests: `[name].test.ts` or `[name].spec.ts` (e.g., `rpc-client.test.ts`, `app-launch.spec.ts`)
|
|
||||||
- Scripts: lowercase with hyphens (e.g., `deploy-to-target.sh`, `run-gate.sh`)
|
|
||||||
|
|
||||||
**Directories:**
|
|
||||||
- Rust workspace members: lowercase (e.g., `archipelago`, `container`, `security`)
|
|
||||||
- Feature directories: PascalCase or lowercase depending on context
|
|
||||||
- `neode-ui/src/views/` — page components (mostly PascalCase)
|
|
||||||
- `neode-ui/src/composables/` — logic hooks (lowercase files with `use` prefix)
|
|
||||||
- `core/archipelago/src/api/rpc/` — RPC modules by domain (lowercase: `bitcoin.rs`, `mesh.rs`)
|
|
||||||
|
|
||||||
**Functions/Methods:**
|
|
||||||
- Async functions: `async fn method_name() -> Result<T>` (no special suffix)
|
|
||||||
- Event handlers: `on[Event]` in Vue (e.g., `@click="onInstall"` calls `onInstall()`)
|
|
||||||
- Computed properties: `computed(() => ...)` (no special name)
|
|
||||||
- Public RPC methods: `pub async fn [domain]_[action](...)` (e.g., `container_install`, `bitcoin_send`)
|
|
||||||
|
|
||||||
**Variables & Constants:**
|
|
||||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `RECONCILER_DEFAULT_INTERVAL`, `MAX_FILE_SIZE`)
|
|
||||||
- State variables: `camelCase` (e.g., `appList`, `isLoading`)
|
|
||||||
- Type aliases: `PascalCase` (e.g., `AppId`, `MeshPeer`)
|
|
||||||
|
|
||||||
**Exports & Modules:**
|
|
||||||
- Re-exports barrel files: `mod.rs` exporting `pub use child::*;`
|
|
||||||
- Private internals: `mod private;` (not `pub mod`)
|
|
||||||
- Path aliases (neode-ui): `@/` = `src/`, `@components/` = `src/components/`
|
|
||||||
|
|
||||||
## Where to Add New Code
|
|
||||||
|
|
||||||
**New RPC Method (Backend):**
|
|
||||||
1. Determine domain (auth, container, bitcoin, mesh, wallet, etc.)
|
|
||||||
2. Add async fn to `core/archipelago/src/api/rpc/[domain].rs`
|
|
||||||
3. Function signature: `pub async fn [action](handler: &RpcHandler, params: [ParamType]) -> Result<[ResponseType]>`
|
|
||||||
4. Register in `core/archipelago/src/api/rpc/mod.rs` dispatcher (line ~350+, search for `match method_name`)
|
|
||||||
5. Test: Unit test in same file with `#[tokio::test]`, or E2E in `tests/`
|
|
||||||
|
|
||||||
**New Frontend View (Page):**
|
|
||||||
1. Create `neode-ui/src/views/[ViewName].vue` (PascalCase)
|
|
||||||
2. Import Router in `neode-ui/src/router/index.ts`, add route
|
|
||||||
3. Add navigation link in `neode-ui/src/components/Nav.vue` (if public-facing)
|
|
||||||
4. State: Use or create Pinia store in `neode-ui/src/stores/`
|
|
||||||
5. Test: Add E2E test in `neode-ui/e2e/` if user-facing flow
|
|
||||||
|
|
||||||
**New Container App:**
|
|
||||||
1. Create directory `apps/[app-name]/`
|
|
||||||
2. Write `manifest.yml` (copy structure from existing app; define interfaces.main.ui, health check, secrets)
|
|
||||||
3. Write `Dockerfile` (base image, deps, entrypoint)
|
|
||||||
4. Add to `app-catalog/catalog.json` with entry (id, version, url to manifest)
|
|
||||||
5. Test: `archipelago container.install { manifest_url: "..." }` on dev node
|
|
||||||
|
|
||||||
**New Component (Frontend):**
|
|
||||||
1. Create `neode-ui/src/components/[ComponentName].vue`
|
|
||||||
2. If reusable logic, extract to `neode-ui/src/composables/use[Logic].ts`
|
|
||||||
3. If shared state, use Pinia store (don't create component-local state)
|
|
||||||
4. Example: `components/AppCard.vue` displays one app (reused in Apps.vue listing)
|
|
||||||
|
|
||||||
**New Utility Function:**
|
|
||||||
- Backend service logic: Add to `core/archipelago/src/[domain].rs` or new file if it's cross-cutting
|
|
||||||
- Frontend helper: Add to `neode-ui/src/utils/` (e.g., `format.ts`, `validate.ts`)
|
|
||||||
- Example: Lightning address validation → `neode-ui/src/utils/validate.ts:validateLightningAddress()`
|
|
||||||
|
|
||||||
**New Test:**
|
|
||||||
- Rust unit test: Inline in source file (`#[test]` or `#[tokio::test]`)
|
|
||||||
- Frontend unit test: `neode-ui/src/composables/__tests__/use[Logic].test.ts`
|
|
||||||
- E2E test: `neode-ui/e2e/[feature].spec.ts` (Playwright)
|
|
||||||
- Integration test: `tests/[feature].sh` (shell script running node commands)
|
|
||||||
|
|
||||||
## Special Directories
|
|
||||||
|
|
||||||
**core/target/**
|
|
||||||
- Purpose: Rust build artifacts (generated)
|
|
||||||
- Generated: Yes (by `cargo build`)
|
|
||||||
- Committed: No (in .gitignore)
|
|
||||||
|
|
||||||
**neode-ui/node_modules/**
|
|
||||||
- Purpose: npm dependencies
|
|
||||||
- Generated: Yes (by `npm install` or `pnpm install`)
|
|
||||||
- Committed: No (in .gitignore)
|
|
||||||
|
|
||||||
**web/dist/**
|
|
||||||
- Purpose: Built frontend output (generated)
|
|
||||||
- Generated: Yes (by `npm run build`)
|
|
||||||
- Committed: No (in .gitignore) — distributed via OTA/ISO
|
|
||||||
|
|
||||||
**/var/lib/archipelago/** (at runtime on node)
|
|
||||||
- Purpose: Data directory (user data, settings, secrets, backups)
|
|
||||||
- Generated: Yes (created by daemon on first boot)
|
|
||||||
- Committed: No (runtime data; contains user secrets)
|
|
||||||
- Subdirs:
|
|
||||||
- `identity/` — Node Ed25519 keys
|
|
||||||
- `secrets/` — Encrypted secret vaults (ChaCha20)
|
|
||||||
- `apps/` — App manifests (disk copies or registry overlays)
|
|
||||||
- `backups/` — Encrypted backup archives
|
|
||||||
- `registries.json` — User-configured Gitea mirrors
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
**/opt/archipelago/** (at runtime on node)
|
|
||||||
- Purpose: System-level Archipelago files (read-only on ISO, writable post-install)
|
|
||||||
- Contains:
|
|
||||||
- `web-ui/` — Built frontend (nginx root)
|
|
||||||
- `bin/archipelago` — Daemon binary
|
|
||||||
- `docker/` — Docker Compose or Quadlet files (orchestration)
|
|
||||||
- `scripts/` — System maintenance scripts
|
|
||||||
- Note: OTA updates overwrite `web-ui/` + `bin/` atomically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Structure analysis: 2026-07-29*
|
|
||||||
@ -1,449 +0,0 @@
|
|||||||
# Testing Patterns
|
|
||||||
|
|
||||||
**Analysis Date:** 2026-07-29
|
|
||||||
|
|
||||||
## Test Framework
|
|
||||||
|
|
||||||
**Frontend:**
|
|
||||||
- Runner: Vitest 3.1.1
|
|
||||||
- Config: `neode-ui/vitest.config.ts`
|
|
||||||
- Environment: jsdom (DOM testing in Node.js)
|
|
||||||
- Globals: enabled (`globals: true`) — `describe`, `it`, `expect` available without imports
|
|
||||||
- Assertion library: built-in Vitest assertions (compatible with Jest)
|
|
||||||
|
|
||||||
**Backend (Rust):**
|
|
||||||
- Framework: built-in `#[test]` attribute and `cargo test`
|
|
||||||
- Command: `cd core && cargo test --workspace --bins`
|
|
||||||
|
|
||||||
**E2E (Browser):**
|
|
||||||
- Framework: Playwright 1.58.2
|
|
||||||
- Config: implicit (tests in `neode-ui/e2e/` directory)
|
|
||||||
|
|
||||||
**Shell Integration Tests:**
|
|
||||||
- Framework: Bats (Bash Automated Testing System)
|
|
||||||
- Location: `tests/lifecycle/bats/`
|
|
||||||
- Config files: `tests/lifecycle/lib/rpc.bash` (RPC wrapper helpers)
|
|
||||||
|
|
||||||
**Run Commands:**
|
|
||||||
```bash
|
|
||||||
# Unit tests (Vitest)
|
|
||||||
npm run test # Run all tests once
|
|
||||||
npm run test:watch # Watch mode, re-run on file changes
|
|
||||||
|
|
||||||
# Rust tests
|
|
||||||
cd core && cargo test --workspace --bins
|
|
||||||
|
|
||||||
# Specific Vitest suite
|
|
||||||
npm run test -- src/composables/__tests__/useFileType.test.ts
|
|
||||||
|
|
||||||
# Shell lifecycle tests (from repo root)
|
|
||||||
ARCHY_PASSWORD=password123 tests/lifecycle/run.sh # Read-only tests
|
|
||||||
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 tests/lifecycle/run.sh # Include destructive
|
|
||||||
|
|
||||||
# Release gate (5× iterations, must run ON the target node)
|
|
||||||
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 \
|
|
||||||
tests/lifecycle/run-gate.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test File Organization
|
|
||||||
|
|
||||||
**Location:**
|
|
||||||
- Frontend: co-located with source in `__tests__/` subdirectories
|
|
||||||
- Example: `src/composables/useFileType.ts` → `src/composables/__tests__/useFileType.test.ts`
|
|
||||||
- Example: `src/api/rpc-client.ts` → `src/api/__tests__/rpc-client.test.ts`
|
|
||||||
- E2E: separate `e2e/` directory at root of frontend
|
|
||||||
- Shell: `tests/lifecycle/bats/` directory
|
|
||||||
|
|
||||||
**Naming:**
|
|
||||||
- Vitest: `*.test.ts` or `*.spec.ts` suffix (`.test.ts` preferred)
|
|
||||||
- Playwright: `*.spec.ts` suffix
|
|
||||||
- Bats: `*.bats` suffix
|
|
||||||
- Rust unit: same file with `#[test]` functions at the bottom or in submodules
|
|
||||||
|
|
||||||
**Structure:**
|
|
||||||
```
|
|
||||||
neode-ui/
|
|
||||||
├── src/
|
|
||||||
│ ├── composables/
|
|
||||||
│ │ ├── useFileType.ts
|
|
||||||
│ │ └── __tests__/
|
|
||||||
│ │ ├── useFileType.test.ts
|
|
||||||
│ │ ├── useNavSounds.test.ts
|
|
||||||
│ │ └── ... (other composable tests)
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── rpc-client.ts
|
|
||||||
│ │ └── __tests__/
|
|
||||||
│ │ └── rpc-client.test.ts
|
|
||||||
│ └── stores/
|
|
||||||
│ ├── controller.ts
|
|
||||||
│ └── (no tests found for stores in exploration)
|
|
||||||
├── e2e/
|
|
||||||
│ ├── app-launch.spec.ts
|
|
||||||
│ ├── intro-experience.spec.ts
|
|
||||||
│ └── visual-regression.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Structure
|
|
||||||
|
|
||||||
**Vitest Suite Organization:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
|
||||||
|
|
||||||
describe('getFileCategory', () => {
|
|
||||||
it('returns folder for directories', () => {
|
|
||||||
expect(getFileCategory('', true)).toBe('folder')
|
|
||||||
expect(getFileCategory('jpg', true)).toBe('folder')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('identifies image extensions', () => {
|
|
||||||
expect(getFileCategory('jpg', false)).toBe('image')
|
|
||||||
expect(getFileCategory('png', false)).toBe('image')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useFileType', () => {
|
|
||||||
it('returns correct category and computed values for an image', () => {
|
|
||||||
const ext = ref('jpg')
|
|
||||||
const isDir = ref(false)
|
|
||||||
const result = useFileType(ext, isDir)
|
|
||||||
|
|
||||||
expect(result.category.value).toBe('image')
|
|
||||||
expect(result.isImage.value).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reacts to ref changes', () => {
|
|
||||||
const ext = ref('jpg')
|
|
||||||
const isDir = ref(false)
|
|
||||||
const result = useFileType(ext, isDir)
|
|
||||||
|
|
||||||
expect(result.category.value).toBe('image')
|
|
||||||
ext.value = 'mp3'
|
|
||||||
expect(result.category.value).toBe('audio')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
- `describe()` blocks group related tests by function or component
|
|
||||||
- `it()` blocks test a single behavior (flat structure, no nesting of describe blocks observed)
|
|
||||||
- `beforeEach()` / `afterEach()` hooks for setup/teardown per test
|
|
||||||
- `beforeAll()` / `afterAll()` hooks for suite-level setup (e.g., login in bats tests)
|
|
||||||
- Assertions use `expect(actual).toBe(expected)` or `expect(actual).toEqual(object)`
|
|
||||||
|
|
||||||
**Playwright E2E Structure:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { expect, test, type Page } from '@playwright/test'
|
|
||||||
|
|
||||||
async function login(page: Page) {
|
|
||||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.setItem('neode_intro_seen', '1')
|
|
||||||
})
|
|
||||||
// ... fill form, submit
|
|
||||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
|
||||||
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
|
|
||||||
|
|
||||||
await login(page)
|
|
||||||
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
|
||||||
|
|
||||||
const appCard = page.locator('[data-controller-container]', {
|
|
||||||
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
|
||||||
}).first()
|
|
||||||
|
|
||||||
await appCard.waitFor({ timeout: 30_000 })
|
|
||||||
await expect(appCard.locator('button')).toBeVisible()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bats Shell Test Structure:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bats
|
|
||||||
# tests/lifecycle/bats/bitcoin-knots.bats
|
|
||||||
|
|
||||||
load '../lib/rpc.bash'
|
|
||||||
|
|
||||||
setup_file() {
|
|
||||||
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
|
|
||||||
export ARCHY_FORCE_LOGIN=1
|
|
||||||
rpc_login
|
|
||||||
unset ARCHY_FORCE_LOGIN
|
|
||||||
}
|
|
||||||
|
|
||||||
teardown_file() {
|
|
||||||
rpc_logout_local
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "container-list includes bitcoin-knots" {
|
|
||||||
run rpc_result container-list
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
echo "$output" | jq -e '.[] | select(.name == "bitcoin-knots")' >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "container-status returns a valid status object" {
|
|
||||||
run rpc_call container-status '{"app_id":"bitcoin-knots"}'
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mocking
|
|
||||||
|
|
||||||
**Framework (Vitest):** `vi` from Vitest; global stub support
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const mockFetch = vi.fn()
|
|
||||||
vi.stubGlobal('fetch', mockFetch)
|
|
||||||
|
|
||||||
// Import after stubbing
|
|
||||||
const { rpcClient } = await import('../rpc-client')
|
|
||||||
|
|
||||||
// In tests:
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error('fetch failed'))
|
|
||||||
|
|
||||||
// Assertions on mock calls:
|
|
||||||
expect(mockFetch).toHaveBeenCalledOnce()
|
|
||||||
const [url, init] = mockFetch.mock.calls[0]!
|
|
||||||
expect(url).toBe('/rpc/v1')
|
|
||||||
expect(init.method).toBe('POST')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vue Test Utils:**
|
|
||||||
- Component mounting: `mount(Component, { global: { mocks: { $ver: displayVersion } } })`
|
|
||||||
- Props tested by passing to mount options
|
|
||||||
- Events tested by listening to emitted events
|
|
||||||
|
|
||||||
**What to Mock:**
|
|
||||||
- External HTTP requests (fetch, axios)
|
|
||||||
- Timers (for timeout logic; `vi.useFakeTimers()`)
|
|
||||||
- Global objects (localStorage, console, window.location)
|
|
||||||
|
|
||||||
**What NOT to Mock:**
|
|
||||||
- Vue reactivity (ref, computed) — these are core to component behavior
|
|
||||||
- RPC client methods in component tests — prefer integration-style testing
|
|
||||||
- Built-in assertions (expect) — always available
|
|
||||||
- Pinia stores in unit tests of composables that use them — store directly if needed
|
|
||||||
|
|
||||||
## Fixtures and Factories
|
|
||||||
|
|
||||||
**Test Data:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
statusText,
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
// ... other Response properties
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage:
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Location:**
|
|
||||||
- Fixtures (test data, helper functions) defined inline in test files or in small helper modules
|
|
||||||
- No central fixture factory observed; each test file is self-contained
|
|
||||||
- Shell test helpers in `tests/lifecycle/lib/rpc.bash` (RPC wrapper for bats)
|
|
||||||
|
|
||||||
## Coverage
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Frontend: 80% branch coverage (set in `vitest.config.ts` thresholds)
|
|
||||||
- Rust: no explicit threshold; pragmatic testing of public APIs
|
|
||||||
- Shell: coverage tracked per app in `tests/lifecycle/TESTING.md` (lifecycle matrix)
|
|
||||||
|
|
||||||
**View Coverage:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate coverage report
|
|
||||||
npm run test -- --coverage
|
|
||||||
|
|
||||||
# Output formats: text, text-summary, html
|
|
||||||
# Config in vitest.config.ts: reporter: ['text', 'text-summary']
|
|
||||||
```
|
|
||||||
|
|
||||||
**Coverage Scope (Frontend):**
|
|
||||||
- Included: `src/api/*.ts`, `src/stores/*.ts`, `src/composables/*.ts`, `src/utils/*.ts`, `src/services/*.ts`, `src/router/*.ts`
|
|
||||||
- Excluded: test files (`src/**/__tests__/**`), type definitions (`*.d.ts`), entry point (`src/main.ts`)
|
|
||||||
|
|
||||||
## Test Types
|
|
||||||
|
|
||||||
**Unit Tests (Vitest):**
|
|
||||||
- Scope: individual functions, composables, utility modules
|
|
||||||
- Approach: fast, isolated, mock external dependencies
|
|
||||||
- Example: `useFileType.test.ts` tests `getFileCategory`, `useFileType`, `formatSize`, `formatDate` independently
|
|
||||||
- Latency: ~5s for full suite; individual tests <1s
|
|
||||||
|
|
||||||
**Integration Tests (Vitest + RPCClient):**
|
|
||||||
- Scope: RPC client with mocked fetch, authentication flows, retry logic
|
|
||||||
- Approach: more complex setup, test interactions between layers
|
|
||||||
- Example: `rpc-client.test.ts` tests 70+ scenarios (login, TOTP, federation, package operations)
|
|
||||||
- Latency: ~30s for full suite
|
|
||||||
|
|
||||||
**E2E Tests (Playwright):**
|
|
||||||
- Scope: real browser, real app instance, user journeys (login → navigate → interact)
|
|
||||||
- Approach: full app stack running; no mocks of UI layer
|
|
||||||
- Example: `app-launch.spec.ts` tests app card discovery and launch via button click
|
|
||||||
- Latency: 30–120s per test depending on app startup time
|
|
||||||
|
|
||||||
**Lifecycle Tests (Bats):**
|
|
||||||
- Scope: container operations (install, start, stop, restart, uninstall) on a live node
|
|
||||||
- Approach: RPC calls to backend, shell commands for verification, destructive operations tier-gated
|
|
||||||
- Tiers:
|
|
||||||
- L0 unit: Rust unit tests (cargo test)
|
|
||||||
- L1 RPC: JSON-RPC API responses (bats + rpc.bash)
|
|
||||||
- L2 UI: HTTP probe of app URLs (bats + ui-probes.bash)
|
|
||||||
- L3 lifecycle survival: container restart/reboot survival (bats, gated)
|
|
||||||
- Latency: 30–120s per suite depending on tier and container startup
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
**Async Testing (Vitest):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it('makes a successful RPC call and returns the result', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
|
||||||
|
|
||||||
const result = await rpcClient.call<{ did: string }>({
|
|
||||||
method: 'node.did',
|
|
||||||
params: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ did: 'did:key:z123' })
|
|
||||||
expect(mockFetch).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- `async` keyword on test function
|
|
||||||
- `await` for async operations
|
|
||||||
- No explicit promise handling; expect called after `await` completes
|
|
||||||
- Timeouts set via test config or `{ timeout: N }` in individual tests
|
|
||||||
|
|
||||||
**Error Testing (Vitest):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it('throws after max retries on persistent 502', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
|
|
||||||
|
|
||||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws immediately on non-retryable HTTP errors', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
|
|
||||||
|
|
||||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
|
|
||||||
expect(mockFetch).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- `expect(...).rejects.toThrow(message)` for expected rejections
|
|
||||||
- Mock returns set per-call (`mockResolvedValueOnce`, `mockResolvedValue`)
|
|
||||||
- Retry logic verified via call count assertions (`toHaveBeenCalledTimes`)
|
|
||||||
|
|
||||||
**Timer Mocking (Vitest):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
|
|
||||||
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
|
||||||
|
|
||||||
const result = await rpcClient.call({ method: 'test' })
|
|
||||||
|
|
||||||
expect(result).toBe('ok')
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- Fake timers enable testing of timeout/retry delays without blocking real time
|
|
||||||
- `shouldAdvanceTime: true` auto-advances clock for non-blocking tests
|
|
||||||
- Clean up with `vi.useRealTimers()` after each test
|
|
||||||
|
|
||||||
**Vue Component Testing (Vue Test Utils):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it('returns correct values for audio', () => {
|
|
||||||
const ext = ref('mp3')
|
|
||||||
const isDir = ref(false)
|
|
||||||
const result = useFileType(ext, isDir)
|
|
||||||
|
|
||||||
expect(result.category.value).toBe('audio')
|
|
||||||
expect(result.isAudio.value).toBe(true)
|
|
||||||
expect(result.isImage.value).toBe(false)
|
|
||||||
expect(result.iconColor.value).toBe('text-orange-400')
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- Refs created with `ref()` passed as test inputs
|
|
||||||
- Computed values accessed via `.value`
|
|
||||||
- No mount overhead for pure composable logic
|
|
||||||
|
|
||||||
**Playwright Browser Testing:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
|
||||||
await login(page)
|
|
||||||
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
|
||||||
|
|
||||||
const appCard = page.locator('[data-controller-container]', {
|
|
||||||
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
|
||||||
}).first()
|
|
||||||
|
|
||||||
await appCard.waitFor({ timeout: 30_000 })
|
|
||||||
await expect(appCard.locator('button')).toBeVisible()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- Locators used to find elements (CSS selector, role, text)
|
|
||||||
- Wait timeouts on slow networks (30s for app startup)
|
|
||||||
- `waitUntil: 'domcontentloaded'` or `'networkidle'` for page load
|
|
||||||
- Screenshots and video recording available via config
|
|
||||||
|
|
||||||
## Test Configuration Details
|
|
||||||
|
|
||||||
**Vitest Config (`vitest.config.ts`):**
|
|
||||||
- Environment: jsdom
|
|
||||||
- Globals: enabled (no imports needed)
|
|
||||||
- Setup file: `vitest.setup.ts` (mocks global Vue config like `$ver`)
|
|
||||||
- Coverage provider: v8
|
|
||||||
- Coverage threshold: 80% branches
|
|
||||||
- Excluded from coverage: tests, types, main.ts
|
|
||||||
|
|
||||||
**Playwright Config (implicit, environment variables used):**
|
|
||||||
- Base URL: derived from `VITE_*` env vars in dev
|
|
||||||
- Timeouts: per-test overrides via `{ timeout: N }`
|
|
||||||
- Retry: 0 (no automatic retries; explicit in tests via polling)
|
|
||||||
- Config environment variables: `ARCHY_PASSWORD`, `ARCHY_APP_ID`, `ARCHY_EXPECTED_LAUNCH_URL`
|
|
||||||
|
|
||||||
**Shell Test Config (environment variables):**
|
|
||||||
- `ARCHY_PASSWORD`: login password (required)
|
|
||||||
- `ARCHY_ALLOW_DESTRUCTIVE`: enable stop/start/restart/uninstall tests
|
|
||||||
- `ARCHY_ALLOW_CASCADE_DESTRUCTIVE`: enable uninstall/reinstall on throwaway app
|
|
||||||
- `ARCHY_ITERATIONS`: loop count for release gate (5× for production readiness)
|
|
||||||
- `ARCHY_FORCE_LOGIN`: fresh RPC token per test file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Testing analysis: 2026-07-29*
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
# Synthesis Summary
|
|
||||||
|
|
||||||
Ingest mode: new (fresh bootstrap; no existing PROJECT.md/REQUIREMENTS.md/ROADMAP.md)
|
|
||||||
Synthesized: 2026-07-29
|
|
||||||
Precedence applied: ADR > SPEC > PRD > DOC (no per-doc overrides)
|
|
||||||
|
|
||||||
## Doc counts by type
|
|
||||||
|
|
||||||
- ADR: 10 (all locked, Status: Accepted, confidence: high)
|
|
||||||
- SPEC: 1 (confidence: high)
|
|
||||||
- PRD: 0
|
|
||||||
- DOC: 0
|
|
||||||
- UNKNOWN: 0
|
|
||||||
- Total: 11
|
|
||||||
|
|
||||||
## Decisions locked (10)
|
|
||||||
|
|
||||||
All in `intel/decisions.md`:
|
|
||||||
- ADR-001 Podman over Docker — docs/adr/001-podman-over-docker.md
|
|
||||||
- ADR-002 did:key (Ed25519) node identity — docs/adr/002-did-key-method.md
|
|
||||||
- ADR-003 Nostr relays for node + app discovery — docs/adr/003-nostr-for-discovery.md
|
|
||||||
- ADR-004 Tor hidden services for inter-node RPC/control plane — docs/adr/004-tor-for-peer-communication.md
|
|
||||||
- ADR-005 ChaCha20-Poly1305 + Argon2id backup encryption — docs/adr/005-chacha20-backup-encryption.md
|
|
||||||
- ADR-006 Nostr relays for marketplace discovery (trust tiers) — docs/adr/006-nostr-marketplace-discovery.md
|
|
||||||
- ADR-007 Bilateral DID federation trust via single-use invite codes — docs/adr/007-did-federation-trust.md
|
|
||||||
- ADR-008 Dual keys (Ed25519 + secp256k1) from one master seed — docs/adr/008-dual-key-strategy.md
|
|
||||||
- ADR-009 Manifest-level container security enforcement — docs/adr/009-manifest-container-security.md
|
|
||||||
- ADR-011 DWN deprioritization (Nostr + Tor federation instead) — docs/adr/011-dwn-deprioritization.md
|
|
||||||
|
|
||||||
## Requirements extracted (0)
|
|
||||||
|
|
||||||
No PRDs in ingest set. `intel/requirements.md` records the absence.
|
|
||||||
|
|
||||||
## Constraints (7 entries)
|
|
||||||
|
|
||||||
From docs/app-manifest-spec.md, in `intel/constraints.md`:
|
|
||||||
- schema: 4 (top-level `app:` block, ContainerConfig, SecurityPolicy + validation, Volumes)
|
|
||||||
- api-contract: 1 (lifecycle hooks)
|
|
||||||
- protocol: 2 (Quadlet installation/reconciler semantics, distribution channels: signed catalog + Nostr marketplace)
|
|
||||||
- nfr: 0
|
|
||||||
|
|
||||||
## Context topics (0)
|
|
||||||
|
|
||||||
No DOC-type documents. `intel/context.md` records the absence.
|
|
||||||
|
|
||||||
## Conflicts
|
|
||||||
|
|
||||||
- Blockers: 0
|
|
||||||
- Competing variants: 0
|
|
||||||
- Auto-resolved: 0
|
|
||||||
- Informational notes: 4 (ADR-003/006 consistent overlap; SPEC validation narrower than ADR-009 mandates — silence, not contradiction; ADR-010 numbering gap; acyclic cross-ref graph)
|
|
||||||
|
|
||||||
Detail: `.planning/INGEST-CONFLICTS.md`
|
|
||||||
|
|
||||||
## Cycle detection
|
|
||||||
|
|
||||||
Cross-ref graph acyclic (max depth well under cap). All 11 docs synthesized; no docs excluded.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- Decisions: `.planning/intel/decisions.md`
|
|
||||||
- Requirements: `.planning/intel/requirements.md`
|
|
||||||
- Constraints: `.planning/intel/constraints.md`
|
|
||||||
- Context: `.planning/intel/context.md`
|
|
||||||
- Conflict report: `.planning/INGEST-CONFLICTS.md`
|
|
||||||
- Raw classifications: `.planning/intel/classifications/*.json`
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/001-podman-over-docker.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-001: Podman Over Docker",
|
|
||||||
"summary": "Chose Podman over Docker as the container runtime for rootless, daemonless operation with native systemd integration.",
|
|
||||||
"scope": ["Podman", "Docker", "container runtime", "rootless containers", "systemd integration", "archy-net network"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/002-did-key-method.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-002: DID Key Method for Node Identity",
|
|
||||||
"summary": "Chose did:key (Ed25519) as the primary DID method for node identity; self-contained and offline-capable, with federation trust lists mitigating rotation/revocation gaps.",
|
|
||||||
"scope": ["did:key", "node identity", "DID methods", "Ed25519 keys", "peer authentication", "federation trust lists", "verifiable credentials"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/003-nostr-for-discovery.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-003: Nostr Relays for Node and App Discovery",
|
|
||||||
"summary": "Chose Nostr relays (NIP-78, kind 30078) for decentralized node discovery and marketplace app manifest distribution.",
|
|
||||||
"scope": [
|
|
||||||
"Nostr relays",
|
|
||||||
"node discovery",
|
|
||||||
"app discovery",
|
|
||||||
"marketplace app manifests",
|
|
||||||
"NIP-78",
|
|
||||||
"NIP-33 replaceable events",
|
|
||||||
"trust scoring",
|
|
||||||
"relay caching"
|
|
||||||
],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/004-tor-for-peer-communication.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-004: Tor Hidden Services for Peer Communication",
|
|
||||||
"summary": "Chose Tor hidden services (.onion) for all inter-node RPC/control-plane communication; bulk data pulled from registries instead.",
|
|
||||||
"scope": ["Tor hidden services", "inter-node communication", "federation sync", "archy-tor container", "RPC/control plane", "NAT traversal"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/005-chacha20-backup-encryption.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-005: ChaCha20-Poly1305 for Backup Encryption",
|
|
||||||
"summary": "Chose ChaCha20-Poly1305 AEAD with Argon2id key derivation for encrypting backups at rest, over AES-256-GCM and XChaCha20-Poly1305.",
|
|
||||||
"scope": ["backup encryption", "ChaCha20-Poly1305", "Argon2id key derivation", "AEAD", "nonce handling"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/006-nostr-marketplace-discovery.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-006: Nostr Relays for Marketplace Discovery",
|
|
||||||
"summary": "Chose Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server.",
|
|
||||||
"scope": ["Nostr relays", "app manifest discovery", "marketplace", "trust scoring", "trust tiers", "manifest signature verification"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/007-did-federation-trust.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-007: DID-Based Federation Trust",
|
|
||||||
"summary": "Chose bilateral DID-based verification with single-use invite codes over Tor for establishing federation trust between nodes, with Trusted/Observer/Untrusted levels.",
|
|
||||||
"scope": ["federation", "DID verification", "invite codes", "trust levels", "Tor hidden services", "Ed25519 keys"],
|
|
||||||
"cross_refs": ["ADR-003"],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/008-dual-key-strategy.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)",
|
|
||||||
"summary": "Maintain two key pairs per node identity: Ed25519 for DID/Web5 operations, secp256k1 for Nostr/Bitcoin/Lightning, both derived from one master seed.",
|
|
||||||
"scope": ["node identity", "Ed25519", "secp256k1", "DID documents", "verifiable credentials", "federation authentication", "backup encryption", "Nostr event publishing", "node discovery", "Lightning Network", "key derivation", "master seed"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/009-manifest-container-security.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-009: Manifest-Level Container Security Enforcement",
|
|
||||||
"summary": "Enforce mandatory container security defaults (readonly root, no-new-privileges, non-root UID, dropped capabilities, pinned tags) at the manifest level during container creation.",
|
|
||||||
"scope": ["container security", "app manifests", "manifest validation", "podman container creation", "security defaults", "capability restrictions", "seccomp", "core/container module"],
|
|
||||||
"cross_refs": ["docs/app-manifest-spec.md", "core/container/src/", "core/security/src/"],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/adr/011-dwn-deprioritization.md",
|
|
||||||
"type": "ADR",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "ADR-011: DWN Deprioritization",
|
|
||||||
"summary": "Deprioritizes Web5 DWN spec compliance after TBD's shutdown; keeps existing custom DWN store code and prioritizes Nostr plus Tor federation for peer sync.",
|
|
||||||
"scope": ["DWN (Decentralized Web Node)", "Web5", "dwn_store.rs", "Nostr", "federation", "peer discovery", "peer data sync"],
|
|
||||||
"cross_refs": [],
|
|
||||||
"locked": true,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"source_path": "/home/archipelago/Projects/archy/docs/app-manifest-spec.md",
|
|
||||||
"type": "SPEC",
|
|
||||||
"confidence": "high",
|
|
||||||
"manifest_override": false,
|
|
||||||
"title": "App Manifest Specification",
|
|
||||||
"summary": "Defines the declarative manifest.yml schema for apps: top-level fields, container config, security policy, volumes, hooks, installation semantics, and signed-catalog/marketplace distribution.",
|
|
||||||
"scope": [
|
|
||||||
"app manifest.yml schema",
|
|
||||||
"ContainerConfig (image/build, networks, derived_env, secret_env, generated_secrets, generated_certs)",
|
|
||||||
"SecurityPolicy (capabilities allow-list, network_policy, readonly_root)",
|
|
||||||
"volumes and bind-mount confinement",
|
|
||||||
"lifecycle hooks (post_install, pre_start)",
|
|
||||||
"health checks and interfaces",
|
|
||||||
"Quadlet installation and reconciler semantics",
|
|
||||||
"signed app catalog distribution",
|
|
||||||
"decentralized marketplace distribution"
|
|
||||||
],
|
|
||||||
"cross_refs": [
|
|
||||||
"app-developer-guide.md",
|
|
||||||
"manifest-hooks-design.md",
|
|
||||||
"marketplace-protocol.md",
|
|
||||||
"core/container/src/manifest.rs",
|
|
||||||
"api/rpc/package/stacks.rs"
|
|
||||||
],
|
|
||||||
"locked": false,
|
|
||||||
"precedence": null,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Constraints (from SPECs)
|
|
||||||
|
|
||||||
Extracted from 1 classified SPEC: `docs/app-manifest-spec.md` (accurate as of 2026-07-08). The SPEC self-declares that the canonical schema is the Rust parser in `core/container/src/manifest.rs` — if doc and code disagree, the code wins.
|
|
||||||
|
|
||||||
## App manifest top-level schema (`app:` block)
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: schema
|
|
||||||
- content: Every app is a directory `apps/<id>/` with a `manifest.yml` containing a single top-level `app:` block. Apps are purely declarative — the orchestrator owns the entire lifecycle; no per-app installer code. Required fields: `id` (lowercase alphanumeric + `-`/`_`, must match directory name), `name`, `version`. Optional fields: `description`, `container` (ContainerConfig), `dependencies` (storage/app_id+version/bare string), `resources` (cpu_limit, memory_limit, disk_limit), `security` (SecurityPolicy), `ports` (host/container/protocol), `volumes`, `files` (GeneratedFile: path/content/overwrite; path must sit under a declared bind mount), `environment` (static KEY=value), `health_check` (type/endpoint/path/interval/timeout/retries; `http` is what the monitor exercises), `devices` (must start with `/dev/`), `interfaces` (launch surfaces keyed by name), `hooks` (LifecycleHooks). Unknown keys are absorbed into an `extensions` map (serde flatten) as transitional metadata — not typed schema, not validated.
|
|
||||||
|
|
||||||
## ContainerConfig schema
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: schema
|
|
||||||
- content: Exactly one of `image` or `build` must be present (image XOR build). Fields: `image` (registry reference), `image_signature` (optional), `pull_policy` (default `if-not-present`), `build` ({context, dockerfile default "Dockerfile", tag, build_args}), `network` (literal podman `--network` value; omitted = rootless default isolated network), `network_aliases` (extra DNS names on the network), `entrypoint`, `custom_args`, `derived_env` ({key, template} rendered against host facts at apply time; allowed placeholders only: {{HOST_IP}}, {{HOST_MDNS}}, {{DISK_GB}} plus dependency-resolved facts — never hard-code host specifics), `secret_env` ({key, secret_file} read from /var/lib/archipelago/secrets/<secret_file>, injected as a podman secret so it never appears in `podman inspect` or unit files; secret_file must be a bare filename, no `/` or `..`), `generated_secrets` ({name, kind} materialised by the orchestrator on first use, 0600, rootless service user, idempotent + self-healing; kind ∈ hex16|hex32|base64|bcrypt; bcrypt writes <name>=hash and <name>.pw=plaintext), `generated_certs` ({crt, key, common_name?, sans?} self-signed TLS materialised before create), `data_uid` ("UID:GID" applied to the app's bind-mounted data dir before create).
|
|
||||||
|
|
||||||
## SecurityPolicy schema and validation rules
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: schema
|
|
||||||
- content: Security block defaults: `readonly_root: true`, `no_new_privileges: true`, `capabilities: []` (cap-drop ALL, add back only listed), `network_policy: isolated` (isolated | bridge | host), `apparmor_profile: null` (optional). Validation enforced at `AppManifest::validate()`: capabilities must come from the reviewed allow-list (CHOWN, DAC_OVERRIDE, FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN); `network_policy` must be exactly isolated/bridge/host; no `container:`/`ns:` network modes; devices must be `/dev/*`; bind-mount sources confined to `/var/lib/archipelago` (reviewed exceptions: rootless podman socket and dbus); `derived_env` templates limited to the placeholder allow-list; `secret_env`/`generated_secrets` names must be bare filenames; hook steps validated against the hook allow-list. (Note: the SPEC's documented validation list does not mention ADR-009's non-root UID, pinned-image-tag, or seccomp mandates — see INGEST-CONFLICTS.md INFO entry.)
|
|
||||||
|
|
||||||
## Volumes schema
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: schema
|
|
||||||
- content: Volume entries: `type` ∈ bind | volume | tmpfs; bind entries take `source` (confined to /var/lib/archipelago per validation), `target`, `options` from an allow-list (rw, ro, z, Z, shared, …); tmpfs entries take `target` and `tmpfs_options` (e.g. "rw,noexec,nosuid,size=256m").
|
|
||||||
|
|
||||||
## Lifecycle hooks contract
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: api-contract
|
|
||||||
- content: Hooks are declarative, allow-listed operations that run against the app's own container — never the host (design: manifest-hooks-design.md). `post_install` runs once after install with the container running; supported steps: `copy_from_host` (src relative to an allow-listed root — data dir / web-ui; no absolute paths, no '..') and `exec` (podman exec inside the container). `pre_start` is reserved in the schema; its executor is not yet wired.
|
|
||||||
|
|
||||||
## Installation semantics (Quadlet + reconciler)
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: protocol
|
|
||||||
- content: The orchestrator compiles the manifest into a rootless Podman Quadlet unit under `user.slice` — the container survives backend restarts and reboots. A level-triggered reconciler converges drift every 30 seconds. Multi-container apps are sets of per-member manifests installed together via the stack orchestrator (`api/rpc/package/stacks.rs`) on an app-local network.
|
|
||||||
|
|
||||||
## Manifest distribution channels
|
|
||||||
- source: docs/app-manifest-spec.md
|
|
||||||
- type: protocol
|
|
||||||
- content: Manifests ship two ways. (1) Signed catalog (primary): `releases/app-catalog.json` embeds the full manifest per app with an Ed25519 detached signature verified against the pinned release-root anchor; nodes overlay catalog manifests over disk files — catalog wins for image-only apps; `apps/<id>/manifest.yml` on disk remains the fallback and is still required for build-source apps. (2) Decentralized marketplace: Nostr NIP-78 discovery with DID-signed manifests (marketplace-protocol.md); the marketplace uses its own flatter manifest schema, not this one. Tooling: validate with `scripts/validate-app-manifest.sh`, regenerate catalog with `scripts/generate-app-catalog.py`, drift-checked in CI by `scripts/check-app-catalog-drift.py`.
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# Context (from DOCs)
|
|
||||||
|
|
||||||
No DOC-type documents were present in the ingest set (10 ADRs + 1 SPEC). No context notes extracted.
|
|
||||||
|
|
||||||
This file intentionally records absence rather than repurposing ADR/SPEC content as context.
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
# Decisions (from ADRs)
|
|
||||||
|
|
||||||
Extracted from 10 classified ADRs. All are `locked: true` (Status: Accepted) and cannot be auto-overridden by any lower-precedence source.
|
|
||||||
|
|
||||||
## ADR-001: Podman Over Docker
|
|
||||||
- source: docs/adr/001-podman-over-docker.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use Podman as the container runtime instead of Docker. Rootless by default, daemonless, Docker-compatible, native systemd integration, OCI-compliant. Use `archy-net` custom network for inter-container DNS.
|
|
||||||
- scope: container runtime, rootless containers, systemd integration, archy-net network
|
|
||||||
|
|
||||||
## ADR-002: DID Key Method for Node Identity
|
|
||||||
- source: docs/adr/002-did-key-method.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use `did:key` (Ed25519) as the primary DID method for node identity. Self-contained, offline-capable, local resolution. Known gaps (no rotation, no service endpoints, no revocation) mitigated via federation trust lists and separately-stored service endpoints; future migration to did:peer/did:web possible if rotation is needed.
|
|
||||||
- scope: node identity, DID methods, Ed25519 keys, peer authentication, federation trust lists, verifiable credentials
|
|
||||||
|
|
||||||
## ADR-003: Nostr Relays for Node and App Discovery
|
|
||||||
- source: docs/adr/003-nostr-for-discovery.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use Nostr relays (NIP-78, kind 30078) for both node discovery and marketplace app manifests. Query multiple relays in parallel with dedupe; local cache with 15-minute TTL; trust scoring (DID verification, relay consensus, federation trust); hashtag filtering (`archipelago-marketplace`); NIP-33 replaceable events for updates; Tor-compatible via SOCKS proxy.
|
|
||||||
- scope: node discovery, app discovery, marketplace app manifests, NIP-78, NIP-33 replaceable events, trust scoring, relay caching
|
|
||||||
|
|
||||||
## ADR-004: Tor Hidden Services for Peer Communication
|
|
||||||
- source: docs/adr/004-tor-for-peer-communication.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use Tor hidden services (.onion addresses) for all inter-node communication. Scoped to RPC/control plane only — bulk data (container images) pulled from registries. Retry with backoff; `archy-tor` container runs automatically with host networking; federation sync interval (5 min) tolerates occasional failures.
|
|
||||||
- scope: inter-node communication, federation sync, archy-tor container, RPC/control plane, NAT traversal
|
|
||||||
|
|
||||||
## ADR-005: ChaCha20-Poly1305 for Backup Encryption
|
|
||||||
- source: docs/adr/005-chacha20-backup-encryption.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use ChaCha20-Poly1305 (AEAD) with Argon2id key derivation for backup encryption at rest, chosen over AES-256-GCM and XChaCha20-Poly1305. Random nonce per backup stored alongside ciphertext; Argon2id with 64MB memory cost and 3 iterations for password-to-key derivation.
|
|
||||||
- scope: backup encryption, AEAD, Argon2id key derivation, nonce handling
|
|
||||||
|
|
||||||
## ADR-006: Nostr Relays for Marketplace Discovery
|
|
||||||
- source: docs/adr/006-nostr-marketplace-discovery.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server. Developers publish signed manifests to public relays; nodes query multiple relays; trust scoring via cross-relay verification count, DID-linked developer reputation, optional community endorsements. Trust tiers: Verified (known developer, 3+ relays, DID-verified), Community (2+ relays, valid manifest, unsigned/new developer), Unverified (single relay, new developer). Local relay-response caching; built-in curated list for essential apps; manifest signature verification before installation.
|
|
||||||
- scope: marketplace, app manifest discovery, trust scoring, trust tiers, manifest signature verification
|
|
||||||
|
|
||||||
## ADR-007: DID-Based Federation Trust
|
|
||||||
- source: docs/adr/007-did-federation-trust.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Use bilateral DID-based verification with single-use invite codes for federation trust establishment. Invite code carries DID, .onion address, and shared secret; exchanged out-of-band; both nodes verify DIDs via signed challenges over Tor; ongoing communication is DID-authenticated over Tor hidden services. Trust levels: Trusted (full access), Observer (read-only), Untrusted (blocked). Discovery (ADR-003) finds nodes; federation trusts them.
|
|
||||||
- scope: federation, DID verification, invite codes, trust levels, Tor hidden services, Ed25519 keys
|
|
||||||
- cross-refs: ADR-003
|
|
||||||
|
|
||||||
## ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)
|
|
||||||
- source: docs/adr/008-dual-key-strategy.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Maintain two key pairs per node identity, both derived from one master seed: Ed25519 as canonical identity (DID documents, verifiable credentials, federation auth, backup encryption via X25519 DH) and secp256k1 for Nostr/Bitcoin/Lightning (event publishing, node discovery, Lightning channel auth). Secp256k1 key linked to the DID via Nostr profile (NIP-05). Backup captures the master seed; DID document includes both verification methods.
|
|
||||||
- scope: node identity, key derivation, master seed, Ed25519, secp256k1, DID documents, federation authentication, backup encryption, Nostr event publishing, node discovery, Lightning Network
|
|
||||||
|
|
||||||
## ADR-009: Manifest-Level Container Security Enforcement
|
|
||||||
- source: docs/adr/009-manifest-container-security.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Enforce mandatory container security defaults at the manifest level, applied automatically during container creation. Non-negotiable defaults: `readonly_root: true`, `no_new_privileges: true`, non-root user (UID > 1000), drop ALL capabilities (add back only required), pinned image tags (no `latest`), default seccomp profile. `core/container/` validates manifests (parse → validate → reject violations → apply security context at `podman create`). Optional overrides (`readonly_root: false`, extra capabilities like NET_ADMIN) require explicit listing and documented justification, with audit trail.
|
|
||||||
- scope: container security, app manifests, manifest validation, podman container creation, security defaults, capability restrictions, seccomp
|
|
||||||
- cross-refs: docs/app-manifest-spec.md, core/container/src/, core/security/src/
|
|
||||||
|
|
||||||
## ADR-011: DWN Deprioritization
|
|
||||||
- source: docs/adr/011-dwn-deprioritization.md
|
|
||||||
- status: locked (Accepted)
|
|
||||||
- decision: Deprioritize Web5 DWN spec compliance following TBD's November 2024 shutdown. Keep existing custom DWN store code (`core/archipelago/src/network/dwn_store.rs`) for peer file catalogs and federation state; stop calling it "Web5 DWN" in user-facing text; do not invest in DWN spec compliance; prioritize Nostr + Tor federation for peer discovery and data exchange; re-evaluate only if DIF produces a viable Rust SDK or the spec regains maintainers.
|
|
||||||
- scope: DWN, Web5, dwn_store.rs, Nostr, federation, peer discovery, peer data sync
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# Requirements (from PRDs)
|
|
||||||
|
|
||||||
No PRD documents were present in the ingest set (10 ADRs + 1 SPEC). No requirements extracted.
|
|
||||||
|
|
||||||
Downstream note: requirements for the roadmap must be derived elsewhere (e.g. from user input or a future PRD ingest); this file intentionally records absence rather than inferring requirements from ADR/SPEC content.
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Onboarding Summary
|
|
||||||
|
|
||||||
## Project State
|
|
||||||
- PROJECT.md: present
|
|
||||||
- REQUIREMENTS.md: present
|
|
||||||
- ROADMAP.md: present
|
|
||||||
- STATE.md: present
|
|
||||||
|
|
||||||
## Codebase Context
|
|
||||||
- Brownfield repo: yes
|
|
||||||
- Map readiness: complete
|
|
||||||
- Codebase map: .planning/codebase/ (complete codebase map)
|
|
||||||
- Fast map available: yes
|
|
||||||
|
|
||||||
## Docs Context
|
|
||||||
- Existing ADR/PRD/SPEC/RFC candidates: 11
|
|
||||||
|
|
||||||
## Recommended Next Step
|
|
||||||
- /gsd-manager
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- core/archipelago/src/federation/storage.rs
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-01]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A federation removal issued while an auto-sync pass is in flight leaves the peer removed — a sync's pre-removal node-list snapshot can no longer re-save the removed peer (FED-01 adjacency edge)"
|
|
||||||
- "Two concurrent federation node writes both persist — neither silently loses the other's update (the lost-update class behind the reported 'removed nodes reappear' symptom)"
|
|
||||||
- "Removing the last remaining federated node succeeds, leaves an empty node list, and returns Ok with an empty Vec (FED-01 empty edge)"
|
|
||||||
- "A removal whose tombstone write fails returns Err to the caller instead of reporting success (FED-01 failure-surfacing)"
|
|
||||||
- "The tombstone is durably written before the filtered node list is saved, so an interruption between the two never resurrects the removed peer (FED-01 ordering edge)"
|
|
||||||
- "A partially-written federation node list can never be observed by a concurrent reader — the list is written to a sibling temp file and renamed into place"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "Removing a federation node MUST NOT delete or destroy that peer's local data — no app data directory under /var/lib/archipelago, no mesh message history, no credential store is erased by unfederating; removal revokes trust, it never destroys operator data"
|
|
||||||
category: safety
|
|
||||||
artifacts:
|
|
||||||
- path: core/archipelago/src/federation/storage.rs
|
|
||||||
provides: "Serialized, crash-safe federation node store"
|
|
||||||
contains: "FEDERATION_STORE_LOCK"
|
|
||||||
key_links:
|
|
||||||
- from: core/archipelago/src/federation/storage.rs
|
|
||||||
to: core/archipelago/src/federation/sync.rs
|
|
||||||
via: "update_node_state acquires FEDERATION_STORE_LOCK for its whole load-mutate-save cycle, so a sync pass cannot interleave with remove_node"
|
|
||||||
pattern: "FEDERATION_STORE_LOCK"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Close the concurrency race that lets a removed federation node come back: serialize every
|
|
||||||
read-modify-write against `federation/nodes.json` behind one async lock, and make the node-list
|
|
||||||
write atomic.
|
|
||||||
|
|
||||||
Purpose: FED-01 — "removing a federation node sticks" is the reason this phase exists. RESEARCH.md
|
|
||||||
identifies an unlocked read-modify-write on `federation/nodes.json` as the primary suspect: the 90s
|
|
||||||
auto-sync loop, the 1800s auto-sync loop, `federation.sync-state`, and `federation.remove-node` all
|
|
||||||
load → mutate → save the same file with zero coordination, so a sync task holding a pre-removal
|
|
||||||
snapshot silently re-saves the peer the operator just removed — with no error logged anywhere.
|
|
||||||
Output: `federation/storage.rs` with a module-level `FEDERATION_STORE_LOCK`, inner/outer function
|
|
||||||
split to avoid re-entrancy deadlock, an atomic temp-file+rename node-list write, and three new
|
|
||||||
regression tests that fail without the lock.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@core/archipelago/src/federation/storage.rs
|
|
||||||
@core/archipelago/src/update.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `FEDERATION_STORE_LOCK` | `static tokio::sync::Mutex<()>` | `core/archipelago/src/federation/storage.rs` |
|
|
||||||
| `save_nodes_inner` | private async fn (no lock; atomic temp+rename write) | same |
|
|
||||||
| `load_nodes_inner` | private async fn (no lock) | same |
|
|
||||||
| `tombstone_did_inner` / `untombstone_did_inner` | private async fns (no lock) | same |
|
|
||||||
| `test_concurrent_writes_do_not_lose_updates` | `#[tokio::test]` | same (`mod tests`) |
|
|
||||||
| `test_remove_survives_concurrent_state_sync` | `#[tokio::test]` | same (`mod tests`) |
|
|
||||||
| `test_remove_last_node_leaves_empty_list` | `#[tokio::test]` | same (`mod tests`) |
|
|
||||||
| `test_remove_errors_when_tombstone_write_fails` | `#[tokio::test]` | same (`mod tests`) |
|
|
||||||
|
|
||||||
Public function signatures of `load_nodes`, `save_nodes`, `add_node`, `remove_node`,
|
|
||||||
`set_trust_level`, `update_node`, `update_node_state`, `record_peer_transport`, `tombstone_did`,
|
|
||||||
`untombstone_did`, `load_removed_dids` are **unchanged** — callers in `sync.rs`, `handlers.rs`,
|
|
||||||
`server.rs`, and `mesh/mod.rs` compile untouched.
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — a removal survives an in-flight sync (lock + atomic write)</name>
|
|
||||||
<reversibility rating="reversible">A module-private lock and a temp+rename write are internal
|
|
||||||
to storage.rs behind unchanged public signatures; reverting is a single-file change with no
|
|
||||||
on-disk format change.</reversibility>
|
|
||||||
<files>core/archipelago/src/federation/storage.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/storage.rs` — the whole file (532 lines). Note in particular:
|
|
||||||
`load_nodes` (L51), `record_peer_transport` (L120), `save_nodes` (L149), `add_node` (L161,
|
|
||||||
calls `untombstone_did`), `remove_node` (L180, calls `tombstone_did`), `tombstone_did` (L214),
|
|
||||||
`untombstone_did` (L237), `set_trust_level` (L256), `update_node` (L272), `update_node_state`
|
|
||||||
(L292), and the existing `#[cfg(test)] mod tests` (L341) with its `make_node(did, onion)`
|
|
||||||
helper and `tempfile::tempdir()` convention.
|
|
||||||
- `core/archipelago/src/update.rs` lines 25-40 — `UPDATE_OP_LOCK`, the in-repo precedent for
|
|
||||||
"two async call sites race on one on-disk resource". Copy its doc-comment style (name the
|
|
||||||
concrete historical incident, then state the acquisition policy).
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md` — section "Async static lock
|
|
||||||
for a racy on-disk resource" and "core/archipelago/src/federation/storage.rs (locking fix)".
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- `test_concurrent_writes_do_not_lose_updates`: under `#[tokio::test(flavor = "multi_thread", worker_threads = 4)]`,
|
|
||||||
seed one node, then `tokio::join!` an `add_node(B)` with a `set_trust_level(A, Observer)`;
|
|
||||||
afterwards `load_nodes` returns 2 nodes AND node A's trust level is Observer. Without the
|
|
||||||
lock one of the two writes is lost.
|
|
||||||
- `test_remove_survives_concurrent_state_sync`: under the multi-thread flavor, loop 50 times:
|
|
||||||
fresh tempdir, seed nodes A and B, `tokio::join!(remove_node(A), update_node_state(A, snapshot))`,
|
|
||||||
then assert `load_nodes` contains no entry whose `did` is A and `load_removed_dids` contains A.
|
|
||||||
Without the lock this reliably fails within 50 iterations.
|
|
||||||
- `test_remove_last_node_leaves_empty_list`: seed exactly one node, remove it, assert the
|
|
||||||
returned Vec is empty, `load_nodes` returns an empty Vec (not an error), and the DID is
|
|
||||||
tombstoned.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write the three tests FIRST in the existing `mod tests` block and confirm they fail (run the
|
|
||||||
verify command and capture the failure) before writing the fix.
|
|
||||||
|
|
||||||
Then add at module scope, immediately after the existing `use` block:
|
|
||||||
`static FEDERATION_STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());`
|
|
||||||
with a doc comment that names the failure it prevents — a `federation.remove-node` RPC racing
|
|
||||||
the 90s auto-sync pass, whose pre-removal `load_nodes` snapshot re-saves the removed peer with
|
|
||||||
no error logged — and states the acquisition policy: acquire with `.lock().await`, never
|
|
||||||
`try_lock`. Reject-on-contention is wrong here: a rejected federation write reproduces the very
|
|
||||||
lost-write symptom the lock exists to stop, unlike `update.rs` where rejecting a second
|
|
||||||
concurrent download is the desired UX.
|
|
||||||
|
|
||||||
Restructure so no public function can deadlock on itself. For each function that both takes the
|
|
||||||
lock and calls another locking function, extract the body into a private `*_inner` fn that does
|
|
||||||
NOT acquire, and make the public fn a thin wrapper: acquire the guard, call the inner(s), drop.
|
|
||||||
Required inner fns for this task: `load_nodes_inner`, `save_nodes_inner`, `tombstone_did_inner`,
|
|
||||||
`untombstone_did_inner`. `remove_node` (which calls `tombstone_did`) and `add_node` (which calls
|
|
||||||
`untombstone_did`) must call the `*_inner` variants under a single held guard so tombstone +
|
|
||||||
node-list save are one critical section.
|
|
||||||
|
|
||||||
Convert the node-list write in `save_nodes_inner` to atomic replace: serialize to a sibling path
|
|
||||||
formed by appending a `.tmp` suffix to the resolved nodes file path in the same directory, write
|
|
||||||
it with `tokio::fs::write`, then `tokio::fs::rename` it onto the real path. Keep the existing
|
|
||||||
`.context(...)` error strings so callers' messages are unchanged. Same-directory rename is
|
|
||||||
required — a cross-filesystem rename is not atomic.
|
|
||||||
|
|
||||||
In THIS task route `load_nodes`, `save_nodes`, `remove_node`, `tombstone_did`,
|
|
||||||
`untombstone_did`, and `update_node_state` through the lock. The remaining mutators are Task 2.
|
|
||||||
|
|
||||||
Preserve `remove_node`'s existing ordering exactly: the retain/`bail!`-on-not-found check, then
|
|
||||||
the tombstone write with its failure propagated via `.context("persist removal tombstone")?`,
|
|
||||||
then the node-list save. Do not weaken that ordering or its error propagation.
|
|
||||||
|
|
||||||
Build gotcha from CLAUDE.md: if the build hits `rust-lld: undefined hidden symbol`, that is
|
|
||||||
incremental-cache corruption — re-run with `CARGO_INCREMENTAL=0`.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation::storage</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation::storage` exits 0.
|
|
||||||
- `grep -c 'FEDERATION_STORE_LOCK' core/archipelago/src/federation/storage.rs` is at least 7.
|
|
||||||
- `grep -c 'tokio::sync::Mutex::const_new' core/archipelago/src/federation/storage.rs` equals 1.
|
|
||||||
- `grep -c 'fs::rename' core/archipelago/src/federation/storage.rs` is at least 1.
|
|
||||||
- `grep -Eq 'async fn test_remove_survives_concurrent_state_sync' core/archipelago/src/federation/storage.rs` succeeds.
|
|
||||||
- `grep -Eq 'async fn test_concurrent_writes_do_not_lose_updates' core/archipelago/src/federation/storage.rs` succeeds.
|
|
||||||
- `grep -Eq 'async fn test_remove_last_node_leaves_empty_list' core/archipelago/src/federation/storage.rs` succeeds.
|
|
||||||
- `grep -Eq 'flavor = "multi_thread"' core/archipelago/src/federation/storage.rs` succeeds (the
|
|
||||||
race tests are useless on the single-threaded default runtime).
|
|
||||||
- The SUMMARY records the captured pre-fix failure output for at least one of the three tests.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The removal-vs-sync race is closed at the storage layer and proven by a test that fails without the lock; the node list is written atomically.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure</name>
|
|
||||||
<files>core/archipelago/src/federation/storage.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/storage.rs` as left by Task 1 — specifically the four
|
|
||||||
mutators not yet routed through the lock: `record_peer_transport` (L120 pre-change),
|
|
||||||
`add_node`, `set_trust_level`, `update_node`.
|
|
||||||
- The existing test `test_remove_nonexistent_node_errors` (L445 pre-change) — mirror its
|
|
||||||
assertion style for the new failure test.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Route `add_node`, `set_trust_level`, `update_node`, and `record_peer_transport` through
|
|
||||||
`FEDERATION_STORE_LOCK` using the same wrapper + `*_inner` split established in Task 1. Every
|
|
||||||
public function in this module that performs a load → mutate → save cycle must hold the guard
|
|
||||||
for the whole cycle; none may call another lock-acquiring public function while holding it.
|
|
||||||
|
|
||||||
Add `test_remove_errors_when_tombstone_write_fails`: seed a node, then make the tombstone write
|
|
||||||
fail by pre-creating the removed-nodes path as a directory (a directory cannot be replaced by a
|
|
||||||
file write), call `remove_node`, and assert the result is `Err` AND that `load_nodes` still
|
|
||||||
contains the node — a removal whose tombstone never landed must not have half-applied. This is
|
|
||||||
the FED-01 "a failed removal surfaces an error instead of silently no-opping" criterion at the
|
|
||||||
storage layer.
|
|
||||||
|
|
||||||
Do not change any public signature and do not touch `load_invites`/`save_invites` (a separate
|
|
||||||
file with no cross-writer).
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation` exits 0.
|
|
||||||
- `grep -Eq 'async fn test_remove_errors_when_tombstone_write_fails' core/archipelago/src/federation/storage.rs` succeeds.
|
|
||||||
- `grep -c 'FEDERATION_STORE_LOCK.lock().await' core/archipelago/src/federation/storage.rs` is at least 9.
|
|
||||||
- `cd core && cargo build -p archipelago` exits 0 with no new warnings in `federation::storage`
|
|
||||||
(dead-code warnings on unused `*_inner` fns mean a mutator was missed).
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0 — no caller in `sync.rs`, `handlers.rs`,
|
|
||||||
`server.rs`, or `mesh/mod.rs` was broken by the refactor.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| federated peer → `federation::sync` → node store | A remote peer's state snapshot crosses into local persisted trust state |
|
|
||||||
| operator RPC (`federation.remove-node`) → node store | An authenticated local operator action mutates trust membership |
|
|
||||||
| process → `federation/nodes.json` on disk | Multiple concurrent async tasks write one file; a crash can leave it partial |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-01 | Tampering | `federation::storage` concurrent read-modify-write | high | mitigate | `FEDERATION_STORE_LOCK` held across the whole load-mutate-save cycle in every mutator (Tasks 1-2); regression test proves a removal survives a concurrent sync |
|
|
||||||
| T-01-02 | Elevation of Privilege | a removed (untrusted) peer regaining federation membership via the race | high | mitigate | Same lock; plus the pre-existing tombstone check in `merge_transitive_peers` and `handle_federation_peer_joined` is left intact and re-verified by `cargo test -p archipelago federation` |
|
|
||||||
| T-01-03 | Denial of Service | a partial `nodes.json` write on crash making the node list unreadable | medium | mitigate | Atomic temp-file + same-directory `fs::rename` in `save_nodes_inner` (Task 1) |
|
|
||||||
| T-01-04 | Denial of Service | lock contention stalling the federation RPC surface | low | accept | Federation writes are infrequent (90s loop + operator actions); `.lock().await` queues rather than rejects, and every critical section is a bounded file read+write |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago federation` — green.
|
|
||||||
- `cd core && cargo test -p archipelago` — green (no caller regressions).
|
|
||||||
- The pre-fix failure of `test_remove_survives_concurrent_state_sync` is recorded in the SUMMARY as
|
|
||||||
evidence the test is fail-first and not vacuous.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Every read-modify-write in `federation/storage.rs` is serialized behind one module-level async mutex with no re-entrancy path.
|
|
||||||
- The node list is written atomically (temp file + same-directory rename).
|
|
||||||
- Four new tests exist and pass; at least one is demonstrated to fail without the lock.
|
|
||||||
- Public signatures unchanged; the full `archipelago` test suite is green.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md` when done.
|
|
||||||
Commit with `git add` by explicit path (another agent shares this tree — never `git add -A`), then
|
|
||||||
push per CLAUDE.md: `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,301 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 02
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- neode-ui/mock-backend.js
|
|
||||||
- neode-ui/scripts/mock-rpc-parity.mjs
|
|
||||||
- neode-ui/package.json
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-04]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Every mesh.* and federation.* RPC method the neode-ui frontend calls has a matching handler in mock-backend.js — the demo never answers a UI call with 'Method not found'"
|
|
||||||
- "Renaming a mesh peer on the demo persists: mesh.contacts-save then mesh.contacts-list returns the saved alias, mirroring the daemon's handle_mesh_contacts_save/list behavior"
|
|
||||||
- "A reaction, reply, edit, delete, or forward performed on the demo mutates the demo message store and is visible on the next mesh.messages read — it is not a bare ok acknowledgement"
|
|
||||||
- "The demo's transport decision for an attachment matches the daemon's size tiers (auto under 1024 bytes, chooser in the 1024..2300 band, tor-only above 2300) — no demo-only chooser modal"
|
|
||||||
- "An automated parity check fails when a UI-called mesh.*/federation.* method has no mock-backend handler, so the gap class is caught before manual demo testing"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "The demo/mock backend MUST NOT gain behavior that diverges from the real daemon — it must never invent a demo-only modal, a demo-only response shape, or a success path a real node does not produce; every mirrored handler cites the daemon source file and line range it mirrors"
|
|
||||||
category: transparency
|
|
||||||
artifacts:
|
|
||||||
- path: neode-ui/scripts/mock-rpc-parity.mjs
|
|
||||||
provides: "Static UI-call vs mock-handler cross-reference plus a live RPC smoke sequence"
|
|
||||||
min_lines: 60
|
|
||||||
- path: neode-ui/mock-backend.js
|
|
||||||
provides: "mesh.contacts-list/save, stateful message-mutation handlers, and the 10 previously-missing UI-called methods"
|
|
||||||
contains: "mesh.contacts-list"
|
|
||||||
key_links:
|
|
||||||
- from: neode-ui/scripts/mock-rpc-parity.mjs
|
|
||||||
to: neode-ui/mock-backend.js
|
|
||||||
via: "spawns mock-backend.js on MOCK_BACKEND_PORT and posts a scripted JSON-RPC sequence"
|
|
||||||
pattern: "MOCK_BACKEND_PORT"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Finish demo/real mesh parity: the demo backend answers every mesh and federation RPC the UI calls,
|
|
||||||
and the message-mutation calls actually mutate demo state instead of returning a bare acknowledgement.
|
|
||||||
|
|
||||||
Purpose: FED-04. Attachment-send parity already landed on main (`c2ce71c6`) — `mesh.send-content-inline`
|
|
||||||
/ `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` now mirror the daemon's tier
|
|
||||||
logic. RESEARCH.md and a fresh cross-reference of `neode-ui/src/**` against `mock-backend.js` show
|
|
||||||
what remains: **12 methods the UI calls that have no case at all** (they fall through to a
|
|
||||||
`Method not found` error the frontend swallows in `try/catch`), and **six ack-only stubs** that
|
|
||||||
never touch the demo message store, so reactions/edits/deletes silently do not render on the demo.
|
|
||||||
Output: those gaps closed, plus a repeatable parity harness so this class of drift is caught by a
|
|
||||||
command instead of by squinting at the browser console.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@neode-ui/mock-backend.js
|
|
||||||
@core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `neode-ui/scripts/mock-rpc-parity.mjs` | new node script (static cross-reference + live smoke) | new file |
|
|
||||||
| `test:mock-parity` | npm script | `neode-ui/package.json` |
|
|
||||||
| `MOCK_BACKEND_PORT` | env var override for the mock's listen port | `neode-ui/mock-backend.js` |
|
|
||||||
| `mesh.contacts-list`, `mesh.contacts-save` | new mock RPC cases | `neode-ui/mock-backend.js` |
|
|
||||||
| `mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`, `mesh.assistant-status`, `mesh.assistant-configure` | new mock RPC cases | same |
|
|
||||||
| `federation.nodes`, `federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request` | new mock RPC cases | same |
|
|
||||||
| `store.mesh.contacts`, `store.mesh.scheduled` | new per-session mock store buckets | same |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer">
|
|
||||||
<name>Task 1: End-to-end — alias a mesh peer on the demo and it sticks, proven by a parity harness</name>
|
|
||||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs, neode-ui/package.json</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/mock-backend.js` lines 4300-4500 — the `mesh.transport-advice` case and the comment
|
|
||||||
block above it (the house convention: mirror the daemon and cite the source file), the
|
|
||||||
`mesh.send-content-inline` case for how a handler mutates `currentStore().mesh.dynamic`, and
|
|
||||||
the ack-only stub block at the end of the mesh cases.
|
|
||||||
- `neode-ui/mock-backend.js` lines 5495-5530 — the per-session store shape (`mesh: { dynamic: [], blobs: {} }`)
|
|
||||||
and `currentStore()`.
|
|
||||||
- `neode-ui/mock-backend.js` lines 80-90 and 5710-5730 — the `PORT` constant and the `server.listen` call.
|
|
||||||
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` — `handle_mesh_contacts_list` (from L1218)
|
|
||||||
and `handle_mesh_contacts_save` (from L1253): the real merge of `state.contacts` (a
|
|
||||||
`ContactEntry` map with `alias`, `notes`, `pinned`, `blocked`) over `state.peers`, and the
|
|
||||||
exact response shape the UI consumes.
|
|
||||||
- `neode-ui/src/api/rpc-client.ts` lines 795-820 — the `mesh.contacts-list` / `mesh.contacts-save`
|
|
||||||
wrappers and their params shape.
|
|
||||||
- `neode-ui/src/views/Mesh.vue` — the two call sites (on mount, and on peer rename) to confirm
|
|
||||||
which response fields are read.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add a `contacts` bucket (a plain object keyed by peer contact id) to the per-session mock store
|
|
||||||
alongside the existing `dynamic` and `blobs` keys.
|
|
||||||
|
|
||||||
Implement `mesh.contacts-save`: accept the same params the daemon's handler takes, upsert
|
|
||||||
`{ alias, notes, pinned, blocked }` for the given peer key into the session `contacts` bucket,
|
|
||||||
and return the same result shape the real handler returns. Implement `mesh.contacts-list`:
|
|
||||||
merge the session `contacts` bucket over the demo's `mesh.peers` list exactly as the daemon
|
|
||||||
merges `state.contacts` over `state.peers`, and return the same field names. Follow the house
|
|
||||||
convention already used above `mesh.transport-advice`: a comment naming
|
|
||||||
`typed_messages.rs handle_mesh_contacts_list` / `handle_mesh_contacts_save` as the source of
|
|
||||||
truth, so a future reader knows where to re-check parity.
|
|
||||||
|
|
||||||
Change the hardcoded listen port to read an env override first, defaulting to the existing
|
|
||||||
value, so a harness can bind an ephemeral port without colliding with a running dev preview.
|
|
||||||
Use the env var name `MOCK_BACKEND_PORT`.
|
|
||||||
|
|
||||||
Create `neode-ui/scripts/mock-rpc-parity.mjs` with two stages and a non-zero exit on any failure:
|
|
||||||
(1) STATIC — scan `neode-ui/src/**` for every `'mesh.<verb>'` / `'federation.<verb>'` string
|
|
||||||
literal, scan `mock-backend.js` for every `case '<method>':`, and report methods called by the UI
|
|
||||||
with no mock case. Print the offending method names. (2) LIVE — spawn `node mock-backend.js`
|
|
||||||
with `MOCK_BACKEND_PORT` set to a free port, poll `/rpc/v1` until ready (bounded ~10s), then POST
|
|
||||||
a scripted JSON-RPC sequence and assert on the responses: `mesh.contacts-save` with an alias,
|
|
||||||
then `mesh.contacts-list` returns that alias for that peer. Kill the child in a `finally` block.
|
|
||||||
Do not use `|| echo`-style fallbacks anywhere in the script or its npm wiring — a failed spawn,
|
|
||||||
a failed fetch, or a missing field must propagate as a non-zero exit, never a passing run that
|
|
||||||
measured nothing.
|
|
||||||
|
|
||||||
Register it as the `test:mock-parity` npm script in `neode-ui/package.json`.
|
|
||||||
|
|
||||||
In this task the STATIC stage is expected to still report the other missing methods; make it
|
|
||||||
print them and exit non-zero only when the LIVE stage fails or when a method from an explicit
|
|
||||||
`KNOWN_GAPS` array is missing. Task 2 empties `KNOWN_GAPS` to zero entries.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && node --check mock-backend.js` exits 0.
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 and its output contains the alias
|
|
||||||
round-trip assertion result.
|
|
||||||
- `grep -c "case 'mesh.contacts-list'" neode-ui/mock-backend.js` equals 1.
|
|
||||||
- `grep -c "case 'mesh.contacts-save'" neode-ui/mock-backend.js` equals 1.
|
|
||||||
- `grep -c 'MOCK_BACKEND_PORT' neode-ui/mock-backend.js` is at least 1.
|
|
||||||
- `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 2 (the pre-existing
|
|
||||||
transport-advice citation plus the new contacts citation).
|
|
||||||
- `node -e "process.exit(require('./neode-ui/package.json').scripts['test:mock-parity'] ? 0 : 1)"` exits 0.
|
|
||||||
- Killing the harness leaves no stray listener: `cd neode-ui && node scripts/mock-rpc-parity.mjs && node scripts/mock-rpc-parity.mjs` exits 0 twice in a row.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Peer aliasing works end-to-end on the demo and a single command proves it, with the remaining method gaps enumerated by name.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Close the remaining ten UI-called methods with no mock handler</name>
|
|
||||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
|
|
||||||
<read_first>
|
|
||||||
- The STATIC-stage output from Task 1 — the authoritative live list. As of planning it is:
|
|
||||||
`mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`,
|
|
||||||
`mesh.assistant-status`, `mesh.assistant-configure`, `federation.nodes`,
|
|
||||||
`federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request`.
|
|
||||||
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the real handler names each of
|
|
||||||
these methods dispatches to, so the mock mirrors the right handler.
|
|
||||||
- `neode-ui/mock-backend.js` — the existing `federation.list-nodes`, `federation.list-pending-requests`,
|
|
||||||
`federation.approve-request`, and `federation.reject-request` cases, for the response shapes
|
|
||||||
the sibling federation methods must match.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add a case for each remaining method, mirroring the real handler's response shape (read the
|
|
||||||
Rust handler named by the dispatcher before writing each one) and citing it in a comment the way
|
|
||||||
the contacts handlers do.
|
|
||||||
|
|
||||||
Behavioral requirements, not bare acknowledgements: `mesh.clear-all` empties the session
|
|
||||||
`dynamic` message array; `mesh.schedule-message` pushes into a new session `scheduled` bucket
|
|
||||||
and returns the created entry's id; `mesh.list-scheduled` returns that bucket;
|
|
||||||
`mesh.cancel-scheduled` removes by id and reports whether an entry was actually removed;
|
|
||||||
`federation.nodes` returns the same node array `federation.list-nodes` returns (the UI treats
|
|
||||||
them as aliases); `federation.cancel-request` removes the request from the pending-requests
|
|
||||||
bucket the existing approve/reject cases operate on.
|
|
||||||
|
|
||||||
Then set the harness's `KNOWN_GAPS` array to empty so the STATIC stage exits non-zero on ANY
|
|
||||||
UI-called method without a mock case, and extend the LIVE stage with one assertion per newly
|
|
||||||
stateful method that has observable state: schedule a message then list it and assert it is
|
|
||||||
present; cancel it and assert it is gone; clear-all then read `mesh.messages` and assert the
|
|
||||||
dynamic messages are gone.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero reported missing methods.
|
|
||||||
- `cd neode-ui && grep -c 'KNOWN_GAPS' scripts/mock-rpc-parity.mjs` is at least 1 and the array
|
|
||||||
literal it is assigned is empty.
|
|
||||||
- Each of the ten method names appears exactly once as a `case '<method>':` in `mock-backend.js`.
|
|
||||||
- Deliberately deleting one `case` line makes `node scripts/mock-rpc-parity.mjs` exit non-zero
|
|
||||||
(fail-first proof); restore the line afterward and record the check in the SUMMARY.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The demo answers every mesh and federation RPC the UI calls, and the parity harness is proven to fail when it does not.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Make the message-mutation stubs mutate demo state</name>
|
|
||||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/mock-backend.js` — the ack-only stub block covering `mesh.send-reaction`,
|
|
||||||
`mesh.send-reply`, `mesh.send-read-receipt`, `mesh.edit-message`, `mesh.delete-message`,
|
|
||||||
`mesh.forward-message`, `mesh.send-channel` (currently a shared bare-acknowledgement case),
|
|
||||||
and the `mesh.send-content-inline` case above it for the message-object shape pushed into
|
|
||||||
`currentStore().mesh.dynamic`.
|
|
||||||
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-976 — reply / reaction /
|
|
||||||
read-receipt / forward handlers, and lines 1065-1180 — edit / delete. Note the stable
|
|
||||||
`sender_pubkey` + `sender_seq` message key these operate on, not the local `id`.
|
|
||||||
- `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` field set the demo
|
|
||||||
objects must match (`id`, `direction`, `peer_contact_id`, `peer_name`, `plaintext`,
|
|
||||||
`timestamp`, `delivered`, `encrypted`, `transport`, `message_type`, `typed_payload`,
|
|
||||||
`sender_pubkey`, `sender_seq`).
|
|
||||||
- `neode-ui/src/views/Mesh.vue` and `neode-ui/src/stores/mesh.ts` — how the UI reads reactions,
|
|
||||||
edited text, and deleted markers, so the mutated shape is the one that renders.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Split the shared acknowledgement case into individual cases that mutate `currentStore().mesh.dynamic`:
|
|
||||||
|
|
||||||
`mesh.send-reaction` — locate the target message by the same key the daemon uses and append or
|
|
||||||
toggle the emoji in its reactions collection. `mesh.send-reply` — push a new message whose
|
|
||||||
payload carries the replied-to message key, so the UI renders the quote block.
|
|
||||||
`mesh.send-read-receipt` — mark the target message read. `mesh.edit-message` — replace the
|
|
||||||
target's text and set the edited marker the UI reads. `mesh.delete-message` — apply the same
|
|
||||||
deletion representation the daemon applies (tombstone marker vs removal — read the handler and
|
|
||||||
mirror it, do not choose independently). `mesh.forward-message` — push a copy addressed to the
|
|
||||||
destination peer. `mesh.send-channel` — push a channel-addressed message.
|
|
||||||
|
|
||||||
Leave `mesh.refresh` and `mesh.reboot-radio` as acknowledgements — the daemon's handlers have no
|
|
||||||
message-store effect either, so mirroring means leaving them alone. Add a comment on that pair
|
|
||||||
stating why they remain acknowledgements, so a later reader does not "fix" them into divergence.
|
|
||||||
|
|
||||||
Extend the harness's LIVE stage: send a message, react to it, and assert `mesh.messages` shows
|
|
||||||
the reaction; edit it and assert the text changed and the edited marker is set; delete it and
|
|
||||||
assert the daemon-matching representation; forward it and assert a copy exists for the
|
|
||||||
destination peer.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with the reaction, edit, delete, and
|
|
||||||
forward assertions all reported as passing.
|
|
||||||
- `grep -c "case 'mesh.send-reaction':" neode-ui/mock-backend.js` equals 1 and that case is no
|
|
||||||
longer part of a shared fall-through group with `mesh.refresh`.
|
|
||||||
- `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 4 (each mirrored family
|
|
||||||
cites its daemon source).
|
|
||||||
- `cd neode-ui && npm run build` exits 0 (the mock is dev-only, but the build must not regress).
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Reactions, replies, edits, deletes, and forwards render on the demo exactly as on a real node, proven by the live harness.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
## Planner Assumptions (flagged, unresolved)
|
|
||||||
|
|
||||||
- **FED-04 / spec-less probe, category `unclassified`:** the probe could not classify an edge for
|
|
||||||
FED-04, and no acceptance criterion was invented for it. The parity harness covers the *known*
|
|
||||||
drift class (missing handler, non-mutating handler); it does NOT cover response-shape drift where
|
|
||||||
a mock case exists and returns a differently-shaped success object than the daemon. That residual
|
|
||||||
class is surfaced here rather than silently dropped, and is a candidate finding for the FED-03
|
|
||||||
review in plan 01-07.
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| browser → mock backend `/rpc/v1` | Developer-local demo surface; accepts unauthenticated JSON-RPC on a loopback-bound dev port |
|
|
||||||
| harness child process → mock backend | The parity script spawns and drives the mock on an ephemeral port |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-05 | Spoofing | mock backend impersonating real daemon behavior in a way that hides a real-node bug | medium | mitigate | Every mirrored handler cites the daemon file and line range it mirrors; the parity harness asserts observable state transitions, not acknowledgements |
|
|
||||||
| T-01-06 | Information Disclosure | mock backend binding a non-loopback interface on a developer machine | low | accept | Pre-existing `0.0.0.0` bind is unchanged by this plan; the mock serves only synthetic demo data and ships in no release artifact |
|
|
||||||
| T-01-07 | Tampering | the parity harness leaving an orphaned server process holding a port | low | mitigate | The child is killed in a `finally` block and the acceptance criteria require two consecutive clean runs |
|
|
||||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages are added by this plan — the harness uses only Node built-ins (`node:child_process`, `fetch`, `node:fs`). If any dependency becomes necessary, stop and run the Package Legitimacy Gate before installing |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
|
|
||||||
- `cd neode-ui && npm run build` — green.
|
|
||||||
- Fail-first proof recorded: deleting a `case` line makes the harness exit non-zero.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Zero mesh.*/federation.* methods called by the UI lack a mock handler.
|
|
||||||
- Peer aliasing, reactions, replies, edits, deletes, and forwards all change demo state and render.
|
|
||||||
- A single command reproduces the parity verdict and is proven fail-first.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` when done.
|
|
||||||
Commit staged by explicit path only (a second agent shares this tree), then `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 03
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- neode-ui/src/components/ScreensaverRing.vue
|
|
||||||
- neode-ui/src/components/SendBitcoinModal.vue
|
|
||||||
- neode-ui/src/components/WalletScanModal.vue
|
|
||||||
- neode-ui/src/components/__tests__/ScreensaverRing.test.ts
|
|
||||||
- neode-ui/src/components/__tests__/PaidTick.test.ts
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-06]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "The payment-success tick in SendBitcoinModal renders the screensaver EQ-segment ring, not a CSS ripple burst"
|
|
||||||
- "The payment-success tick in WalletScanModal renders the same EQ-segment ring, so the paid tick is identical on every surface it appears"
|
|
||||||
- "ScreensaverRing exposes a third badge size variant sized 160px on mobile and 192px from 768px up, with --viz-radius 80px/96px, alongside the untouched default and compact variants"
|
|
||||||
- "The badge ring fits inside the modal card without clipping — the success pane's ring container is no larger than the badge diameter at either breakpoint"
|
|
||||||
- "The success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changes"
|
|
||||||
- "SystemDangerZone and Screensaver continue to render the compact and default variants unchanged"
|
|
||||||
- statement: "ScreensaverRing's segment animation is disabled under prefers-reduced-motion for every size variant including the new badge, matching the site-wide reduced-motion convention"
|
|
||||||
verification: backstop
|
|
||||||
prohibitions:
|
|
||||||
- statement: "The paid-tick change MUST NOT alter what the success pane asserts about the payment — the ring is decoration; it must never render a success state for a payment that has not actually settled, and no success-gating condition may be relaxed to make the animation easier to trigger"
|
|
||||||
category: safety
|
|
||||||
artifacts:
|
|
||||||
- path: neode-ui/src/components/ScreensaverRing.vue
|
|
||||||
provides: "badge size variant + reduced-motion guard"
|
|
||||||
contains: "viz-ring-badge"
|
|
||||||
- path: neode-ui/src/components/__tests__/PaidTick.test.ts
|
|
||||||
provides: "Assertions that both paid-tick surfaces render the badge ring"
|
|
||||||
min_lines: 25
|
|
||||||
key_links:
|
|
||||||
- from: neode-ui/src/components/SendBitcoinModal.vue
|
|
||||||
to: neode-ui/src/components/ScreensaverRing.vue
|
|
||||||
via: "success pane renders <ScreensaverRing size=\"badge\" /> layered under the checkmark core"
|
|
||||||
pattern: "ScreensaverRing"
|
|
||||||
- from: neode-ui/src/components/WalletScanModal.vue
|
|
||||||
to: neode-ui/src/components/ScreensaverRing.vue
|
|
||||||
via: "success pane renders <ScreensaverRing size=\"badge\" /> in place of the plain circle"
|
|
||||||
pattern: "ScreensaverRing"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Make the invoice/payment "paid" tick on-brand: the circle around the checkmark becomes the
|
|
||||||
screensaver ring with its outer EQ-segment lines, everywhere the paid tick appears.
|
|
||||||
|
|
||||||
Purpose: FED-06, locked by the user in CONTEXT.md — the paid-tick circle is the ScreensaverRing
|
|
||||||
style, applied consistently to every paid/success tick surface. RESEARCH.md flagged that a naive
|
|
||||||
drop-in overflows the modal card (the existing compact variant is 240-320px against a 96-112px
|
|
||||||
badge); 01-UI-SPEC.md resolved that by deciding on a new `badge` size variant rather than a
|
|
||||||
transform hack, and also recorded that `ScreensaverRing` has no `prefers-reduced-motion` guard at
|
|
||||||
all today — a real gap this phase must close.
|
|
||||||
Output: a third size variant plus a reduced-motion guard in the shared component, both paid-tick
|
|
||||||
call sites swapped, and component tests pinning the result.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@neode-ui/src/components/ScreensaverRing.vue
|
|
||||||
@neode-ui/src/components/Screensaver.vue
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `size: 'default' \| 'compact' \| 'badge'` | widened prop union | `neode-ui/src/components/ScreensaverRing.vue` |
|
|
||||||
| `.viz-ring-badge` | CSS class (160px / 192px, `--viz-radius` 80px / 96px) | same |
|
|
||||||
| reduced-motion media guard on `.viz-segment` | CSS | same |
|
|
||||||
| `neode-ui/src/components/__tests__/ScreensaverRing.test.ts` | new vitest suite | new file |
|
|
||||||
| `neode-ui/src/components/__tests__/PaidTick.test.ts` | new vitest suite | new file |
|
|
||||||
|
|
||||||
<!-- planner-discipline-allow: burst-ring -->
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — the badge ring variant renders as the payment-success tick</name>
|
|
||||||
<files>neode-ui/src/components/ScreensaverRing.vue, neode-ui/src/components/SendBitcoinModal.vue, neode-ui/src/components/__tests__/ScreensaverRing.test.ts, neode-ui/src/components/__tests__/PaidTick.test.ts</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/src/components/ScreensaverRing.vue` — the whole file (about 115 lines): the
|
|
||||||
`withDefaults(defineProps<{ size?: ... }>())` union, the `sizeClass` computed, the two
|
|
||||||
existing size CSS classes with their `min-width: 768px` breakpoints and `--viz-radius`
|
|
||||||
custom properties, and the `segment-pulse` keyframes.
|
|
||||||
- `neode-ui/src/components/SendBitcoinModal.vue` lines 1-30 (the success pane markup: the
|
|
||||||
success-burst container, its three ripple span elements, and the core circle plus checkmark)
|
|
||||||
and lines 680-740 (the corresponding CSS block, including the existing
|
|
||||||
`@media (prefers-reduced-motion: reduce)` rule — copy that exact media-query syntax into
|
|
||||||
ScreensaverRing).
|
|
||||||
- `neode-ui/src/components/Screensaver.vue` — the existing `ScreensaverRing` + `ScreensaverLogo`
|
|
||||||
centred-absolute layering pattern (`position: relative` wrapper, `position: absolute; inset: 0`
|
|
||||||
inner content) to reuse for the checkmark core.
|
|
||||||
- `neode-ui/src/components/__tests__/BaseModal.test.ts` — the house vitest + `@vue/test-utils`
|
|
||||||
conventions for mounting a component in this repo.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-06 Sizing Decision"
|
|
||||||
table (exact diameters and radii) and the "UI Considerations" rows for the paid-tick ring.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- `ScreensaverRing.test.ts`: mounting with `size="badge"` puts `viz-ring-badge` on the root
|
|
||||||
element; mounting with `size="compact"` still yields `viz-ring-compact`; the default mount
|
|
||||||
still yields `viz-ring-default`; the rendered segment count matches the `segmentCount` prop.
|
|
||||||
- `PaidTick.test.ts`: SendBitcoinModal driven into its payment-success state renders exactly one
|
|
||||||
`ScreensaverRing` with `size="badge"`, renders the checkmark core, and renders zero ripple
|
|
||||||
elements; the success amount text is unchanged.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write both test files first and confirm they fail before implementing.
|
|
||||||
|
|
||||||
In `ScreensaverRing.vue`: widen the `size` prop union with a third member `'badge'`, extend
|
|
||||||
`sizeClass` to map it to `viz-ring-badge`, and add a `.viz-ring-badge` CSS rule following the
|
|
||||||
exact shape of the existing two — `width`/`height` 160px and `--viz-radius: 80px` at mobile,
|
|
||||||
then a `@media (min-width: 768px)` block with 192px and `--viz-radius: 96px`. Do not touch
|
|
||||||
`.viz-ring-default` or `.viz-ring-compact`; `Screensaver.vue` and `SystemDangerZone.vue` must
|
|
||||||
keep their current rendering.
|
|
||||||
|
|
||||||
Also inside `ScreensaverRing.vue`, add the missing motion guard so it applies to every variant:
|
|
||||||
a `@media (prefers-reduced-motion: reduce)` block that sets `animation: none` and a static
|
|
||||||
reduced opacity on `.viz-segment`. Use the same media-query syntax as the guard already present
|
|
||||||
in `SendBitcoinModal.vue` so the two read identically.
|
|
||||||
|
|
||||||
In `SendBitcoinModal.vue`'s payment-success pane: import `ScreensaverRing`, replace the three
|
|
||||||
ripple span elements with `<ScreensaverRing size="badge" />`, keep the existing core circle and
|
|
||||||
checkmark markup untouched, and wrap the pair in the Screensaver-style layering (a
|
|
||||||
`position: relative` container sized to the badge diameter, with the core absolutely centred over
|
|
||||||
the ring). Remove the ripple elements' now-dead CSS rules and their keyframes; keep the core and
|
|
||||||
checkmark rules, and keep the existing reduced-motion rule but drop the clause that referenced
|
|
||||||
the removed elements. Do not change the success amount numerals, the SENT copy, the Done button,
|
|
||||||
or any condition that decides when the success pane is shown.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && test -f src/components/__tests__/ScreensaverRing.test.ts && test -f src/components/__tests__/PaidTick.test.ts && npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- Both test files exist and `npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts` exits 0 (the explicit `test -f` guards are required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would otherwise pass vacuously).
|
|
||||||
- `grep -c 'viz-ring-badge' neode-ui/src/components/ScreensaverRing.vue` is at least 2 (computed mapping + CSS rule).
|
|
||||||
- `grep -c 'prefers-reduced-motion' neode-ui/src/components/ScreensaverRing.vue` equals 1.
|
|
||||||
- `grep -Eq '160px' neode-ui/src/components/ScreensaverRing.vue` and `grep -Eq '192px' neode-ui/src/components/ScreensaverRing.vue` both succeed.
|
|
||||||
- `grep -c 'viz-ring-compact' neode-ui/src/components/ScreensaverRing.vue` is unchanged from before the edit (the compact variant is untouched).
|
|
||||||
- `grep -c 'ScreensaverRing' neode-ui/src/components/SendBitcoinModal.vue` is at least 2 (import + usage).
|
|
||||||
- `grep -c 'burst-ring' neode-ui/src/components/SendBitcoinModal.vue` equals 0.
|
|
||||||
- `cd neode-ui && npx vitest run` exits 0 — no existing suite regressed.
|
|
||||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'viz-ring-badge' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md: the build can silently no-op, so grep the built bundle for the new string).
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The badge variant exists, the send-payment success tick renders it, and both are pinned by tests that failed before the change.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Bring the scan-modal paid tick to the same ring</name>
|
|
||||||
<files>neode-ui/src/components/WalletScanModal.vue, neode-ui/src/components/__tests__/PaidTick.test.ts</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/src/components/WalletScanModal.vue` around line 232 (the success circle markup — a
|
|
||||||
fixed 24-unit inline-flex circle with the success-ring class) and around line 861 (its CSS
|
|
||||||
rule). Note it has no ripple animation at all today, unlike the send modal.
|
|
||||||
- `neode-ui/src/components/SendBitcoinModal.vue` as left by Task 1 — the layering wrapper to
|
|
||||||
copy verbatim.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the FED-06 sizing table row
|
|
||||||
confirming this call site also uses the badge variant.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Replace WalletScanModal's fixed success circle with the same composition Task 1 established:
|
|
||||||
a `position: relative` container sized to the badge diameter holding `<ScreensaverRing size="badge" />`
|
|
||||||
with the existing checkmark content absolutely centred over it. Import `ScreensaverRing`. Drop
|
|
||||||
the now-unused fixed-size utility classes and the plain-circle CSS rule; keep the checkmark
|
|
||||||
glyph, its colour, and the surrounding copy exactly as they are.
|
|
||||||
|
|
||||||
Extend `PaidTick.test.ts` with a WalletScanModal case asserting its success state renders one
|
|
||||||
`ScreensaverRing` with `size="badge"` and still renders the checkmark.
|
|
||||||
|
|
||||||
Verify on the dev preview before considering this done, per the user requirement recorded in
|
|
||||||
CONTEXT.md: run the dev preview and confirm neither ring is clipped by the modal card's
|
|
||||||
scrolling container at a narrow viewport and at desktop width. Record the observation in the
|
|
||||||
SUMMARY. The blocking human sign-off for this is consolidated into plan 01-07.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts && npm run build</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts` exits 0 and the suite contains both a SendBitcoinModal case and a WalletScanModal case.
|
|
||||||
- `grep -c 'ScreensaverRing' neode-ui/src/components/WalletScanModal.vue` is at least 2.
|
|
||||||
- `grep -c 'success-ring' neode-ui/src/components/WalletScanModal.vue` equals 0.
|
|
||||||
- `cd neode-ui && npx vitest run` exits 0.
|
|
||||||
- `cd neode-ui && npm run build` exits 0.
|
|
||||||
- The SUMMARY records the dev-preview observation for both surfaces at a narrow and a desktop viewport.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Both paid-tick surfaces render the identical branded ring, with no clipping at either breakpoint.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
## Planner Assumptions (flagged, unresolved)
|
|
||||||
|
|
||||||
- **FED-06 / spec-less probe, category `unclassified`:** the probe surfaced an unclassified edge for
|
|
||||||
FED-06 that no defensible acceptance criterion covers. Surfaced rather than dropped: the phase
|
|
||||||
requirement says the ring applies "everywhere the paid tick appears", and a repo-wide grep found
|
|
||||||
exactly two paid-tick surfaces (`SendBitcoinModal.vue`, `WalletScanModal.vue`). If a third
|
|
||||||
success-tick surface is added between planning and execution — or exists under markup this grep
|
|
||||||
did not match — it will not be covered by this plan. The FED-03 review in plan 01-07 re-runs the
|
|
||||||
grep as a check.
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| payment result → success pane render | The only security-relevant edge: what the UI asserts about a payment's settlement |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-08 | Spoofing | success pane rendered for a payment that has not settled | high | mitigate | This plan changes decoration only; the acceptance criteria forbid touching any condition that gates the success pane, and `npx vitest run` on the existing suites must stay green |
|
|
||||||
| T-01-09 | Denial of Service | 48 animated segments rendered inside a modal degrading low-power devices | low | mitigate | The badge variant is the smallest of the three; the new `prefers-reduced-motion` guard disables the animation entirely for users who ask for it |
|
|
||||||
| T-01-10 | Repudiation | the success amount or recipient text changing as a side effect of the swap | medium | mitigate | Tests assert the success amount text is unchanged; the action forbids touching the numerals and copy |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd neode-ui && npx vitest run` — green.
|
|
||||||
- `cd neode-ui && npm run build` — green, and the built bundle contains the new class name.
|
|
||||||
- Dev-preview observation recorded for both modals at narrow and desktop widths.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- A third `badge` size variant exists on the shared ring component; existing variants and their consumers are untouched.
|
|
||||||
- Both paid-tick surfaces render the branded ring with the checkmark layered centred.
|
|
||||||
- A reduced-motion guard covers every variant.
|
|
||||||
- Component tests pin all of the above and were proven to fail before the change.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 04
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- core/archipelago/src/api/rpc/lnd/info.rs
|
|
||||||
- core/archipelago/src/mesh/message_types.rs
|
|
||||||
- core/archipelago/src/mesh/types.rs
|
|
||||||
- core/archipelago/src/mesh/listener/dispatch.rs
|
|
||||||
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
- core/archipelago/src/api/rpc/dispatcher.rs
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-05]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "lnd.getinfo returns this node's Lightning identity_pubkey and its advertised connection URIs, so the UI has something real to copy and share"
|
|
||||||
- "A node with no reachable LND, or an LND that advertises no URI, yields an absent identity rather than a fabricated one — the caller can tell 'not available' from 'available'"
|
|
||||||
- "A meshed peer that advertises Lightning is recorded with its URI on the mesh peer record and is listed by mesh.lightning-peers"
|
|
||||||
- "mesh.lightning-peers returns an empty list, not an error, when no meshed peer has advertised Lightning (FED-05 empty edge, mesh half)"
|
|
||||||
- "A peer that advertises Lightning twice appears once in mesh.lightning-peers, with the most recent URI (FED-05 adjacency edge, mesh half)"
|
|
||||||
- "mesh.lightning-peers returns peers in a deterministic order so the picker list does not reshuffle between reads (FED-05 ordering edge, mesh half)"
|
|
||||||
- "An inbound Lightning advertisement whose URI is not well-formed is rejected and does not overwrite a previously known good URI for that peer"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "A node's Lightning URI MUST NOT be advertised to parties the operator has not chosen to reach — the advertisement is sent on an explicit send, never auto-broadcast to every radio contact in range, and a received URI is never re-broadcast onward to third parties"
|
|
||||||
category: privacy
|
|
||||||
artifacts:
|
|
||||||
- path: core/archipelago/src/api/rpc/lnd/info.rs
|
|
||||||
provides: "identity_pubkey + uris on the lnd.getinfo response"
|
|
||||||
contains: "identity_pubkey"
|
|
||||||
- path: core/archipelago/src/mesh/message_types.rs
|
|
||||||
provides: "LightningInfo typed message + payload"
|
|
||||||
contains: "LightningInfo"
|
|
||||||
key_links:
|
|
||||||
- from: core/archipelago/src/mesh/listener/dispatch.rs
|
|
||||||
to: core/archipelago/src/mesh/types.rs
|
|
||||||
via: "inbound LightningInfo envelope writes MeshPeer.lightning_uri"
|
|
||||||
pattern: "lightning_uri"
|
|
||||||
- from: core/archipelago/src/api/rpc/dispatcher.rs
|
|
||||||
to: core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
via: "mesh.lightning-peers and mesh.send-lightning-info match arms"
|
|
||||||
pattern: "mesh.lightning-peers"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Give the platform the two Lightning facts the channel-open UI needs from the mesh side: **this node's
|
|
||||||
own shareable URI**, and **which meshed peers have Lightning installed and what their URI is**.
|
|
||||||
|
|
||||||
Purpose: FED-05, whose scope is LOCKED in CONTEXT.md — the "public/other" list in the channel-open
|
|
||||||
picker is *meshed peer nodes that have Lightning installed*, not `lnd listpeers`, not a curated
|
|
||||||
directory, not a live LN-graph query. That requires peers to advertise a Lightning capability plus
|
|
||||||
their URI over the mesh. RESEARCH.md Pitfall 5 confirms neither datum exists today: `handle_lnd_getinfo`
|
|
||||||
fetches LND's `/v1/getinfo` but its response struct does not deserialize `identity_pubkey` or `uris`,
|
|
||||||
and PATTERNS.md records that mesh peer capability advertisement has **no analog** in the codebase —
|
|
||||||
it is genuinely new surface, to be built on the existing typed-envelope pattern.
|
|
||||||
Output: an extended `lnd.getinfo`, a new `LightningInfo` typed mesh message, a `lightning_uri` field
|
|
||||||
on `MeshPeer`, and two new RPCs (`mesh.lightning-peers`, `mesh.send-lightning-info`).
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@core/archipelago/src/mesh/message_types.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `LndInfo.identity_pubkey: Option<String>` | new response field on `lnd.getinfo` | `core/archipelago/src/api/rpc/lnd/info.rs` |
|
|
||||||
| `LndInfo.uris: Vec<String>` | new response field on `lnd.getinfo` | same |
|
|
||||||
| `LndGetInfoResponse.identity_pubkey` / `.uris` | new deserialized LND REST fields | same |
|
|
||||||
| `MeshMessageType::LightningInfo = 26` (label `lightning_info`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` |
|
|
||||||
| `LightningInfoPayload { uri, alias }` | new CBOR payload struct | same |
|
|
||||||
| `MeshPeer.lightning_uri: Option<String>` | new optional peer field | `core/archipelago/src/mesh/types.rs` |
|
|
||||||
| `handle_mesh_lightning_peers` | new RPC handler (`mesh.lightning-peers`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` |
|
|
||||||
| `handle_mesh_send_lightning_info` | new RPC handler (`mesh.send-lightning-info`) | same |
|
|
||||||
| `mesh.lightning-peers`, `mesh.send-lightning-info` | dispatcher match arms | `core/archipelago/src/api/rpc/dispatcher.rs` |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary</name>
|
|
||||||
<reversibility rating="reversible">Two additive optional fields on an internal RPC response; no
|
|
||||||
consumer breaks if they are removed again.</reversibility>
|
|
||||||
<files>core/archipelago/src/api/rpc/lnd/info.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/api/rpc/lnd/info.rs` lines 1-110 — the `LndInfo` serialize struct, the
|
|
||||||
`LndGetInfoResponse` deserialize struct (which currently declares only `alias`,
|
|
||||||
`num_active_channels`, `num_peers`, `synced_to_chain`, `block_height`), and how
|
|
||||||
`handle_lnd_getinfo` maps one into the other with `unwrap_or_default()`.
|
|
||||||
- `core/archipelago/src/api/rpc/lnd/channels.rs` around `handle_lnd_openchannel` (from L238) —
|
|
||||||
the sibling handler's pubkey validation (66 hex chars) and error-shaping style to mirror.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Deserializing an LND `/v1/getinfo` body that contains `identity_pubkey` and a non-empty `uris`
|
|
||||||
array yields both on the mapped response.
|
|
||||||
- Deserializing a body with neither field present succeeds and yields `identity_pubkey: None`
|
|
||||||
and an empty `uris` vector — never a fabricated or placeholder identity.
|
|
||||||
- A body whose `identity_pubkey` is not 66 hex characters yields `identity_pubkey: None` rather
|
|
||||||
than propagating a malformed key that `lnd.openchannel` would later reject.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a
|
|
||||||
`serde_json::from_str::<LndGetInfoResponse>(...)` over three fixture bodies (full, empty,
|
|
||||||
malformed pubkey) plus the mapping function. Extract the `LndGetInfoResponse` → `LndInfo`
|
|
||||||
identity mapping into a small pure function so it is testable without an HTTP call; keep the
|
|
||||||
existing HTTP flow otherwise untouched.
|
|
||||||
|
|
||||||
Add `identity_pubkey: Option<String>` and `uris: Vec<String>` to `LndGetInfoResponse` with
|
|
||||||
`#[serde(default)]`, and the corresponding `identity_pubkey: Option<String>` and
|
|
||||||
`uris: Vec<String>` to the serialized `LndInfo`. Validate the pubkey shape the same way
|
|
||||||
`handle_lnd_openchannel` does (66 hexadecimal characters) before forwarding it; on failure
|
|
||||||
forward `None`, and log at `warn!` naming the field.
|
|
||||||
|
|
||||||
Do not change any existing `LndInfo` field name or type — `HomeWalletCard.vue`, `Server.vue`,
|
|
||||||
and `Web5Wallet.vue` all read this response.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago lnd::info</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago lnd::info` exits 0 with at least 3 test cases.
|
|
||||||
- `grep -c 'identity_pubkey' core/archipelago/src/api/rpc/lnd/info.rs` is at least 4.
|
|
||||||
- `grep -c 'uris' core/archipelago/src/api/rpc/lnd/info.rs` is at least 3.
|
|
||||||
- `cd core && cargo build -p archipelago` exits 0.
|
|
||||||
- The SUMMARY records the pre-implementation failing output of the fixture tests.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>`lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored</name>
|
|
||||||
<reversibility rating="costly">`MeshMessageType` is a radio wire format shared with every fleet
|
|
||||||
node; the new discriminant and its CBOR payload shape become readable by deployed peers after the
|
|
||||||
next OTA, so changing the payload later needs a coordinated fleet upgrade. Kept additive (unused
|
|
||||||
discriminant, optional payload fields) so old nodes simply ignore it.</reversibility>
|
|
||||||
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/mesh/message_types.rs` lines 28-200 — the `#[repr(u8)] MeshMessageType`
|
|
||||||
enum (highest current discriminant is `AssistResponse = 25`), and the three places every new
|
|
||||||
variant must be added: the enum, `from_u8`, `from_label`, and `label`. Also read
|
|
||||||
`ReactionPayload` (from L533) and `PresencePayload` (from L727) for payload struct conventions,
|
|
||||||
and the `TypedEnvelope` doc comment about `compact_bytes` (a plain derived `Vec<u8>` bloats
|
|
||||||
every message on the wire — this matters on LoRa).
|
|
||||||
- `core/archipelago/src/mesh/types.rs` lines 60-118 — the `MeshPeer` struct and its
|
|
||||||
`#[serde(default)]` optional-field convention (see `lat`/`lon`, `pkc_capable`).
|
|
||||||
- `core/archipelago/src/mesh/listener/dispatch.rs` around lines 430-490 — the
|
|
||||||
`Some(MeshMessageType::Reaction)` and `Some(MeshMessageType::Presence)` inbound arms: how a
|
|
||||||
decoded envelope is matched, its payload deserialized, and peer/message state mutated.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add `LightningInfo = 26` to `MeshMessageType` with a doc comment stating what it advertises and
|
|
||||||
that it is only ever sent on an explicit operator action. Register it in `from_u8` (26),
|
|
||||||
`from_label` ("lightning_info"), and `label`.
|
|
||||||
|
|
||||||
Add `LightningInfoPayload` next to the other payload structs: a required `uri: String` (the
|
|
||||||
`pubkey@host:port` form) and an optional `alias: Option<String>` with `#[serde(default)]`.
|
|
||||||
Follow the surrounding payload structs' serde conventions.
|
|
||||||
|
|
||||||
Add `#[serde(default)] pub lightning_uri: Option<String>` to `MeshPeer`, with a doc comment
|
|
||||||
saying it is set only from a received `LightningInfo` advertisement (or federation seeding in a
|
|
||||||
later plan) and is what the channel-open picker offers as a request target.
|
|
||||||
|
|
||||||
Add an inbound arm in `dispatch.rs` for the new type, mirroring the shape of the `Reaction` and
|
|
||||||
`Presence` arms: deserialize the payload, validate the URI before storing (a `pubkey@host` form
|
|
||||||
whose pubkey part is 66 hex characters; the `:port` suffix is optional), and on success write it
|
|
||||||
onto the resolved `MeshPeer`. On a malformed URI, log at `warn!` and return without touching a
|
|
||||||
previously stored value. Store the newest advertisement when a peer advertises more than once —
|
|
||||||
overwrite, do not accumulate.
|
|
||||||
|
|
||||||
Add unit tests in `message_types.rs` covering the round-trip of the new discriminant through
|
|
||||||
`from_u8`/`from_label`/`label`, and a `dispatch.rs`-level test (or a pure helper test if
|
|
||||||
`dispatch.rs` has no test harness) asserting that a malformed URI leaves a previously stored good
|
|
||||||
URI intact.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago mesh::message_types mesh::listener</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener` exits 0.
|
|
||||||
- `grep -c 'LightningInfo' core/archipelago/src/mesh/message_types.rs` is at least 5 (enum, from_u8, from_label, label, payload doc).
|
|
||||||
- `grep -Eq 'lightning_info' core/archipelago/src/mesh/message_types.rs` succeeds.
|
|
||||||
- `grep -c 'lightning_uri' core/archipelago/src/mesh/types.rs` is at least 1.
|
|
||||||
- `grep -c 'LightningInfo' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Expose the meshed Lightning peers and the send path over RPC</name>
|
|
||||||
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` around `handle_mesh_contacts_list`
|
|
||||||
(from L1218) — the canonical read-handler shape: `self.mesh_service.read().await`, the
|
|
||||||
"Mesh service not running" error, `shared_state()`, then `.read().await` on the relevant map.
|
|
||||||
- The same file around `handle_mesh_send_reaction` (in the L637-976 family) — the canonical
|
|
||||||
send-handler shape: build a payload, wrap in `TypedEnvelope::new(...).with_seq(seq)`, send.
|
|
||||||
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line `"mesh.<verb>" =>
|
|
||||||
self.handle_...(params).await,` registration convention.
|
|
||||||
- `core/archipelago/src/server.rs` around `is_peer_allowed_path` (from L1270) — confirm whether
|
|
||||||
the new RPCs need peer reachability. They are operator-local calls over `/rpc/v1`, which is
|
|
||||||
already in the allow-list; do NOT widen that list.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add `handle_mesh_lightning_peers`: read the mesh peer map, keep only peers whose `lightning_uri`
|
|
||||||
is set, collapse duplicates by the peer's authenticating key (the `MeshPeer` accessor that
|
|
||||||
prefers the verified archipelago identity key over the firmware routing key) keeping the most
|
|
||||||
recently heard entry, and return a stable-sorted array — sort by display name, then by contact
|
|
||||||
id as the tiebreak, so the picker list does not reshuffle between reads. Each entry carries at
|
|
||||||
minimum: contact id, display name, `lightning_uri`, `last_heard`, `reachable`, and `hops`.
|
|
||||||
Returning zero matching peers is an empty array with a success result, never an error.
|
|
||||||
|
|
||||||
Add `handle_mesh_send_lightning_info`: take a target peer identifier in params, read this node's
|
|
||||||
own URI from the `lnd.getinfo` path built in Task 1, refuse with a clear error when no URI is
|
|
||||||
available (LND down, or no advertised URI) rather than sending an empty advertisement, then send
|
|
||||||
a `LightningInfo` envelope to that peer only. It must not broadcast to all contacts: the target
|
|
||||||
is required, and the handler returns an error when it is absent.
|
|
||||||
|
|
||||||
Register both in the dispatcher as `"mesh.lightning-peers"` and `"mesh.send-lightning-info"`,
|
|
||||||
following the existing one-line convention.
|
|
||||||
|
|
||||||
Add tests covering: empty peer map yields an empty array; two advertisements from the same peer
|
|
||||||
yield one entry with the newer URI; ordering is stable across two consecutive calls over the
|
|
||||||
same peer set; `handle_mesh_send_lightning_info` with no target errors.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago mesh</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago mesh` exits 0.
|
|
||||||
- `grep -c '"mesh.lightning-peers"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
|
|
||||||
- `grep -c '"mesh.send-lightning-info"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
|
|
||||||
- `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1.
|
|
||||||
- `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened).
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| radio peer → typed-envelope decode → `MeshPeer` | Untrusted, unauthenticated-by-default RF input mutates local peer state |
|
|
||||||
| LND REST (`/v1/getinfo`) → daemon | Local service response parsed into an RPC payload the UI displays and copies |
|
|
||||||
| operator RPC → outbound mesh send | An operator action that discloses this node's payment endpoint to a chosen peer |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-11 | Spoofing | a radio peer advertising someone else's Lightning URI to redirect a channel open | high | mitigate | The advertisement is stored against the peer's authenticating key (the verified archipelago identity key, never the firmware routing key — see `MeshPeer`'s auth-key accessor doc); the UI in plan 01-06 labels these peers as *request* targets, not trusted opens |
|
|
||||||
| T-01-12 | Tampering | a malformed or oversized URI corrupting stored peer state | high | mitigate | URI shape validated before store (66-hex pubkey part); invalid input leaves any previously stored value untouched; test asserts this |
|
|
||||||
| T-01-13 | Information Disclosure | this node's payment endpoint leaking to every radio contact in range | high | mitigate | `mesh.send-lightning-info` requires an explicit target and errors without one; there is no broadcast path, and a received URI is never re-advertised onward |
|
|
||||||
| T-01-14 | Denial of Service | advertisement flooding growing the peer map unboundedly | medium | accept | The advertisement writes a field on an existing peer record rather than creating records; peer-map growth is governed by the pre-existing contact-discovery limits, unchanged here |
|
|
||||||
| T-01-15 | Elevation of Privilege | a new RPC becoming peer-reachable and letting a remote peer enumerate Lightning peers | high | mitigate | Both RPCs ride the existing `/rpc/v1` operator surface; the acceptance criteria assert `is_peer_allowed_path` is not widened |
|
|
||||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates are introduced. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago` — green.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
|
|
||||||
- Fixture-test failure output captured before the Task 1 implementation.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- `lnd.getinfo` exposes a real identity pubkey and URI list, or an honest absence.
|
|
||||||
- A `LightningInfo` mesh message exists, is validated on receipt, and populates `MeshPeer.lightning_uri`.
|
|
||||||
- `mesh.lightning-peers` returns a deduplicated, deterministically ordered list and an empty array when there are none.
|
|
||||||
- `mesh.send-lightning-info` requires an explicit target and refuses to send an empty advertisement.
|
|
||||||
- The peer HTTP allow-list is unchanged.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 05
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on: ["01-01"]
|
|
||||||
files_modified:
|
|
||||||
- core/archipelago/src/federation/types.rs
|
|
||||||
- core/archipelago/src/federation/storage.rs
|
|
||||||
- core/archipelago/src/federation/sync.rs
|
|
||||||
- core/archipelago/src/api/rpc/federation/handlers.rs
|
|
||||||
- core/archipelago/src/server.rs
|
|
||||||
- neode-ui/src/views/federation/types.ts
|
|
||||||
- neode-ui/src/views/federation/NodeList.vue
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-02]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A federation sync failure is recorded on the peer's node record and surfaced through federation.list-nodes, so the operator sees it in the UI instead of it existing only as a debug log line"
|
|
||||||
- "A successful sync clears a previously recorded sync error for that peer — the badge does not persist after the peer recovers (FED-02 adjacency edge)"
|
|
||||||
- "A periodic sync pass over zero federated nodes is a clean no-op: no error is recorded, nothing is written, and no error surfaces in the UI (FED-02 empty edge)"
|
|
||||||
- "A state snapshot older than the one already stored for a peer does not overwrite the newer one — out-of-order sync responses cannot move a peer's status backwards (FED-02 ordering edge)"
|
|
||||||
- "Exactly one periodic federation sync loop runs in the daemon; the redundant second loop is gone and every behavior unique to it is preserved in the surviving loop"
|
|
||||||
- "Duplicate node entries do not accumulate across sync cycles — after sync settles the node list has one entry per federated node"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "Making sync errors visible MUST NOT expose a peer's onion address, DID, or any transport secret in an error string rendered to a surface wider than the operator's own dashboard — a sync error message names what failed, never credential material"
|
|
||||||
category: privacy
|
|
||||||
artifacts:
|
|
||||||
- path: core/archipelago/src/federation/types.rs
|
|
||||||
provides: "last_sync_error / last_sync_error_at on FederatedNode"
|
|
||||||
contains: "last_sync_error"
|
|
||||||
- path: neode-ui/src/views/federation/NodeList.vue
|
|
||||||
provides: "Operator-visible sync-error badge on a node row"
|
|
||||||
contains: "last_sync_error"
|
|
||||||
key_links:
|
|
||||||
- from: core/archipelago/src/server.rs
|
|
||||||
to: core/archipelago/src/federation/storage.rs
|
|
||||||
via: "the periodic sync loop calls record_sync_result after each peer attempt instead of only debug-logging"
|
|
||||||
pattern: "record_sync_result"
|
|
||||||
- from: core/archipelago/src/api/rpc/federation/handlers.rs
|
|
||||||
to: neode-ui/src/views/federation/NodeList.vue
|
|
||||||
via: "federation.list-nodes emits last_sync_error, the node row renders it as a badge"
|
|
||||||
pattern: "last_sync_error"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Make federation sync converge and stop failing silently: one sync loop instead of two, a per-peer
|
|
||||||
sync error persisted and shown to the operator, and out-of-order snapshots unable to move a peer's
|
|
||||||
state backwards.
|
|
||||||
|
|
||||||
Purpose: FED-02. RESEARCH.md's anti-pattern list is explicit — both periodic sync loops in
|
|
||||||
`server.rs` log failures at `debug!` only, so a peer that has not synced in days looks identical to
|
|
||||||
one that synced a minute ago. The same section notes the two loops (90s at ~L497, 1800s at ~L840)
|
|
||||||
are redundant apart from one tail call, and that the redundancy doubles the write-race exposure that
|
|
||||||
plan 01-01 just locked down. Open Question 1 asks the reviewer to `git log -p` both loop-insertion
|
|
||||||
commits before deleting either — that check is a required step here, not an optional one.
|
|
||||||
Output: `last_sync_error` plumbed store → loop → RPC → UI badge, one surviving loop, and a
|
|
||||||
monotonicity guard on `update_node_state`.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md
|
|
||||||
@core/archipelago/src/federation/types.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `FederatedNode.last_sync_error: Option<String>` | new optional field | `core/archipelago/src/federation/types.rs` |
|
|
||||||
| `FederatedNode.last_sync_error_at: Option<String>` | new optional field | same |
|
|
||||||
| `record_sync_result` | new pub async fn (records or clears a peer's sync error under the store lock) | `core/archipelago/src/federation/storage.rs` |
|
|
||||||
| `last_sync_error`, `last_sync_error_at` on `federation.list-nodes` | new response fields | `core/archipelago/src/api/rpc/federation/handlers.rs` |
|
|
||||||
| `FederatedNode.last_sync_error?` / `.last_sync_error_at?` | new TS interface fields | `neode-ui/src/views/federation/types.ts` |
|
|
||||||
| sync-error badge on a node row | Vue markup + class | `neode-ui/src/views/federation/NodeList.vue` |
|
|
||||||
| the 1800s periodic federation sync loop | **deleted** (its unique tail call moved into the 90s loop) | `core/archipelago/src/server.rs` |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — a failed federation sync becomes visible to the operator</name>
|
|
||||||
<files>core/archipelago/src/federation/types.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/server.rs, neode-ui/src/views/federation/types.ts, neode-ui/src/views/federation/NodeList.vue</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/types.rs` lines 50-146 — `FederatedNode`'s existing optional
|
|
||||||
fields and the doc-comment convention on `last_transport` / `last_transport_at` (a result
|
|
||||||
field written back after each attempt). The new pair mirrors that shape for the failure side.
|
|
||||||
- `core/archipelago/src/federation/storage.rs` as left by plan 01-01 — `record_peer_transport`
|
|
||||||
(the existing "write a result field back after an attempt" function) and the
|
|
||||||
`FEDERATION_STORE_LOCK` wrapper + `*_inner` split convention the new function must follow.
|
|
||||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` `handle_federation_list_nodes`
|
|
||||||
(from L220) — the `serde_json::json!` node object and the `if let Some(...)` conditional-field
|
|
||||||
pattern the new fields must follow.
|
|
||||||
- `core/archipelago/src/server.rs` lines 497-600 — the 90s periodic federation sync loop, its
|
|
||||||
per-peer `sync_with_peer` call and the `debug!(peer = %node.did, error = %e, ...)` arm that
|
|
||||||
currently swallows failures.
|
|
||||||
- `neode-ui/src/views/federation/types.ts` lines 19-33 — the `FederatedNode` TS interface.
|
|
||||||
- `neode-ui/src/views/federation/NodeList.vue` lines 40-140 — the trusted-node and peer rows,
|
|
||||||
`transportBadge()` (L166) and `trustBadgeClass()` for the badge idiom to mirror, and the
|
|
||||||
existing loading row.
|
|
||||||
- `neode-ui/src/views/federation/__tests__/NodeList.test.ts` — the existing suite's mount
|
|
||||||
conventions.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- `record_sync_result(data_dir, did, Err("..."))` sets `last_sync_error` to the message and
|
|
||||||
`last_sync_error_at` to an RFC 3339 timestamp on that node only.
|
|
||||||
- `record_sync_result(data_dir, did, Ok(()))` clears both fields on that node.
|
|
||||||
- `record_sync_result` for a DID that is not in the node list is a no-op returning Ok — a peer
|
|
||||||
removed mid-pass must not be resurrected by an error write.
|
|
||||||
- `federation.list-nodes` emits both fields when set and omits them when unset.
|
|
||||||
- NodeList renders a sync-error badge on a node whose `last_sync_error` is set, and renders no
|
|
||||||
such badge when it is unset.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write the Rust tests and the NodeList component test first and confirm they fail.
|
|
||||||
|
|
||||||
Add `#[serde(default)] pub last_sync_error: Option<String>` and
|
|
||||||
`#[serde(default)] pub last_sync_error_at: Option<String>` to `FederatedNode`, with a doc comment
|
|
||||||
modelled on `last_transport`: these record the outcome of the most recent sync attempt so the
|
|
||||||
operator can tell a stale peer from a healthy one, replacing a debug-only log line. Update the
|
|
||||||
`make_node` test helper in `storage.rs`'s test module so the struct literal still compiles.
|
|
||||||
|
|
||||||
Add `record_sync_result(data_dir: &Path, did: &str, outcome: Result<(), String>) -> Result<()>`
|
|
||||||
to `storage.rs`, acquiring `FEDERATION_STORE_LOCK` and using the `*_inner` load/save functions
|
|
||||||
established in 01-01. Missing DID is a silent Ok. Never create a node entry.
|
|
||||||
|
|
||||||
In `server.rs`'s 90s loop, replace the debug-only failure arm with a call to `record_sync_result`
|
|
||||||
carrying the error's display string, and call it with a success outcome on the success arm.
|
|
||||||
Truncate the recorded message to a bounded length (256 characters) so a pathological error
|
|
||||||
cannot bloat the node file. Keep the existing `debug!` line as well — persisting is additive,
|
|
||||||
not a replacement for logs.
|
|
||||||
|
|
||||||
In `handle_federation_list_nodes`, emit the two fields onto the node object using the same
|
|
||||||
`if let Some(...)` conditional-insert pattern the existing optional fields use. Add the matching
|
|
||||||
optional fields to the TS `FederatedNode` interface.
|
|
||||||
|
|
||||||
In `NodeList.vue`, add a badge on the node row shown only when `last_sync_error` is set: red
|
|
||||||
family (`alert-error`-adjacent classes already in the house style), short label, and the full
|
|
||||||
message plus the timestamp in the element's `title` attribute — the row must stay single-line, so
|
|
||||||
apply the same `truncate` + `:title` treatment the node-name span already uses. Place it beside
|
|
||||||
the existing transport badge, not in place of it. Do not add a new nav entry, card, or view —
|
|
||||||
only this badge inside the existing row.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation && cd ../neode-ui && test -f src/views/federation/__tests__/NodeList.test.ts && npx vitest run src/views/federation/__tests__/NodeList.test.ts</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation` exits 0 and includes a test named for the clear-on-success behavior and one for the missing-DID no-op.
|
|
||||||
- `grep -c 'last_sync_error' core/archipelago/src/federation/types.rs` is at least 2.
|
|
||||||
- `grep -c 'record_sync_result' core/archipelago/src/federation/storage.rs` is at least 1.
|
|
||||||
- `grep -c 'record_sync_result' core/archipelago/src/server.rs` is at least 2 (the failure arm and the success arm).
|
|
||||||
- `grep -c 'last_sync_error' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
|
|
||||||
- `grep -c 'last_sync_error' neode-ui/src/views/federation/types.ts` is at least 1.
|
|
||||||
- `grep -c 'last_sync_error' neode-ui/src/views/federation/NodeList.vue` is at least 1.
|
|
||||||
- `cd neode-ui && npx vitest run src/views/federation/__tests__/NodeList.test.ts` exits 0 with a case asserting the badge is absent when the field is unset (the guard against a badge that always renders).
|
|
||||||
- `cd neode-ui && npm run build` exits 0.
|
|
||||||
- The SUMMARY records the pre-implementation failing output for both the Rust and the component test.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>A sync failure is persisted per peer, travels through the RPC, and renders as a badge the operator can see — and clears when the peer recovers.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Collapse the two periodic sync loops into one</name>
|
|
||||||
<reversibility rating="costly">Deleting a background loop changes daemon runtime behavior across
|
|
||||||
the whole fleet on the next OTA; restoring it means re-deriving code that is gone from the tree
|
|
||||||
rather than flipping a flag. Mitigated by moving — not discarding — the loop's unique tail call
|
|
||||||
and by the required git-history check below.</reversibility>
|
|
||||||
<files>core/archipelago/src/server.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/server.rs` lines 497-600 (the 90s loop, including its asymmetry
|
|
||||||
self-heal `notify_join` re-assertion) and lines 840-910 (the 1800s loop, whose unique tail
|
|
||||||
call is `rpc.refresh_federation_mesh_peers()`).
|
|
||||||
- The output of `git log -p -L 840,910:core/archipelago/src/server.rs` and
|
|
||||||
`git log -p -L 497,600:core/archipelago/src/server.rs` — RESEARCH.md Assumption A2 flags that
|
|
||||||
the 1800s loop may exist for an undocumented reason. Run this BEFORE deleting anything and
|
|
||||||
record the finding in the SUMMARY.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — "Open Questions" item 1.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
First run the two `git log -p -L` commands above and write the answer to Open Question 1 into the
|
|
||||||
SUMMARY: does the 1800s loop do anything the 90s loop does not, beyond
|
|
||||||
`refresh_federation_mesh_peers()`? If the history shows a documented reason to keep it, STOP,
|
|
||||||
do not delete it, and record that as a finding for the FED-03 review instead — the phase then
|
|
||||||
keeps two loops and this task's remaining work is limited to routing both through
|
|
||||||
`record_sync_result`.
|
|
||||||
|
|
||||||
Otherwise: move the `refresh_federation_mesh_peers()` call to the tail of the 90s loop's
|
|
||||||
completed pass (after the per-peer iteration, alongside the existing pass-complete log), thread
|
|
||||||
whatever handle it needs into that task's captured state, and delete the entire 1800s
|
|
||||||
`tokio::spawn` block. Keep the 90s loop's startup settle delay and its asymmetry self-heal
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
Make the surviving loop's zero-node case an explicit clean no-op: when `load_nodes` returns an
|
|
||||||
empty list the pass continues to the next tick without writing anything and without recording a
|
|
||||||
sync error against anyone.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo build -p archipelago && cargo test -p archipelago federation</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo build -p archipelago` exits 0.
|
|
||||||
- `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'federation::sync_with_peer'` equals 1 (comment lines stripped so a doc comment cannot satisfy the gate).
|
|
||||||
- `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'refresh_federation_mesh_peers'` equals 1.
|
|
||||||
- `grep -c 'from_secs(1800)' core/archipelago/src/server.rs` equals 0 <!-- planner-discipline-allow: from_secs(1800) -->
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- The SUMMARY contains the `git log -p -L` finding answering RESEARCH.md Open Question 1, and states explicitly whether the loop was deleted or kept.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>One periodic federation sync loop remains, its predecessor's unique behavior preserved, with the history check recorded.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Stop out-of-order snapshots and duplicates from breaking convergence</name>
|
|
||||||
<files>core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/storage.rs` `update_node_state` (from L292 pre-01-01) — it
|
|
||||||
currently overwrites `last_seen`, `name`, `fips_npub`, and `last_state` unconditionally from
|
|
||||||
whatever snapshot arrives, with no comparison against what is already stored.
|
|
||||||
- `core/archipelago/src/federation/types.rs` — `NodeStateSnapshot.timestamp` is an RFC 3339
|
|
||||||
string; note that a lexicographic compare is only safe for same-offset RFC 3339, so parse it.
|
|
||||||
- `core/archipelago/src/federation/storage.rs` — `dedup_nodes_by_onion` and its two existing
|
|
||||||
tests, for the convergence behavior already present.
|
|
||||||
- `core/archipelago/src/federation/sync.rs` — `merge_transitive_peers` (from L120) and its
|
|
||||||
tombstone check, to confirm the guard added here does not conflict with it.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add a monotonicity guard to `update_node_state`: parse the incoming snapshot's timestamp and the
|
|
||||||
stored `last_state`'s timestamp with `chrono::DateTime::parse_from_rfc3339`; if the incoming one
|
|
||||||
is strictly older, return Ok without mutating the node — a slow sync response that lands after a
|
|
||||||
newer one must not move the peer's status backwards. When either timestamp fails to parse, fall
|
|
||||||
back to the current accept-newest behavior so a peer with a malformed clock is not frozen out,
|
|
||||||
and log at `debug!`. Learning a peer's `fips_npub` is exempt: a stale snapshot may still carry
|
|
||||||
the only copy of a FIPS key this node has, so apply that one field even on a rejected snapshot,
|
|
||||||
and say so in a comment.
|
|
||||||
|
|
||||||
Add tests: a strictly-older snapshot leaves `last_state` and `last_seen` unchanged; an equal
|
|
||||||
timestamp is accepted (idempotent re-sync); a newer snapshot is accepted; a stale snapshot
|
|
||||||
carrying a `fips_npub` this node lacks still populates it; an unparseable timestamp is accepted.
|
|
||||||
|
|
||||||
Add a convergence test asserting that repeatedly applying the same peer's snapshot plus a
|
|
||||||
transitive-peer merge does not grow the node list — one entry per federated node after N cycles.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation` exits 0 with the five snapshot-ordering cases and the convergence case present by name.
|
|
||||||
- `grep -c 'parse_from_rfc3339' core/archipelago/src/federation/storage.rs` is at least 1.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Out-of-order sync responses cannot regress a peer's state, and repeated sync cycles converge to one entry per node.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| federated peer → `sync_with_peer` → node record | A remote peer's snapshot and its timestamp drive local persisted state |
|
|
||||||
| daemon → operator dashboard | A sync error string crosses from the daemon into rendered UI |
|
|
||||||
| background loop → federation node store | The surviving periodic loop is now a writer of error state, not only a reader |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-16 | Tampering | a peer replaying an old snapshot to roll a node's status backwards | high | mitigate | The `update_node_state` monotonicity guard rejects strictly-older snapshots (Task 3), with tests |
|
|
||||||
| T-01-17 | Information Disclosure | a sync error string carrying a peer onion address or transport credential into the UI | medium | mitigate | The recorded message is the error's display string truncated to 256 characters and rendered only on the operator's own dashboard; the prohibition above states the constraint and it is re-checked in the FED-03 review |
|
|
||||||
| T-01-18 | Denial of Service | an unbounded error message bloating `nodes.json` on every failed pass | medium | mitigate | 256-character truncation before persistence (Task 1) |
|
|
||||||
| T-01-19 | Repudiation | a silently-failing sync leaving no record of when a peer was last reachable | high | mitigate | `last_sync_error_at` is written on every attempt outcome; the badge makes staleness visible |
|
|
||||||
| T-01-20 | Denial of Service | deleting the 1800s loop dropping a behavior the fleet depends on | high | mitigate | Mandatory `git log -p -L` history check before deletion, the unique tail call moved rather than dropped, and an explicit STOP path if the history shows a documented reason |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago` — green.
|
|
||||||
- `cd neode-ui && npx vitest run && npm run build` — green.
|
|
||||||
- The SUMMARY answers RESEARCH.md Open Question 1 with git evidence.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- A sync failure is persisted, exposed over RPC, and rendered as an operator-visible badge that clears on recovery.
|
|
||||||
- Exactly one periodic federation sync loop remains, with the deleted loop's unique behavior preserved.
|
|
||||||
- Out-of-order snapshots cannot regress a peer's state; repeated cycles converge to one entry per node.
|
|
||||||
- A zero-node sync pass writes nothing and records no error.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,287 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 06
|
|
||||||
type: execute
|
|
||||||
wave: 3
|
|
||||||
depends_on: ["01-04", "01-05"]
|
|
||||||
files_modified:
|
|
||||||
- core/archipelago/src/federation/types.rs
|
|
||||||
- core/archipelago/src/federation/sync.rs
|
|
||||||
- core/archipelago/src/federation/storage.rs
|
|
||||||
- core/archipelago/src/api/rpc/federation/handlers.rs
|
|
||||||
autonomous: false
|
|
||||||
requirements: [FED-05]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A trusted federated peer's Lightning URI is known locally after sync and is emitted by federation.list-nodes, so the picker can offer a one-click channel open by hostname"
|
|
||||||
- "The Lightning field on the federation sync payload is optional with a serde default, so a node running an older build syncs with a newer one in both directions without error"
|
|
||||||
- "A federated peer that advertises no Lightning URI is emitted without the field rather than with an empty string — the picker can tell 'no Lightning' from 'Lightning at an unknown address'"
|
|
||||||
- "An inbound Lightning URI that is not well-formed is rejected at sync time and never persisted or rendered"
|
|
||||||
- "A stale sync snapshot cannot clear a peer's previously known Lightning URI, consistent with the snapshot-ordering guard from plan 01-05"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "A node's Lightning URI MUST NOT reach a party the operator has not federated with — it must never be re-exported in this node's own outbound peer hints on behalf of a third-party peer, so a peer-of-a-peer cannot harvest payment endpoints by federating one hop away"
|
|
||||||
category: privacy
|
|
||||||
- statement: "Lightning URI sharing MUST NOT be silently enabled in a way the operator cannot see or reverse — whatever default ships, the current sharing state is discoverable from the node's own settings surface and changing it takes effect on the next sync without a data migration"
|
|
||||||
category: transparency
|
|
||||||
artifacts:
|
|
||||||
- path: core/archipelago/src/federation/types.rs
|
|
||||||
provides: "Lightning identity field(s) on NodeStateSnapshot (and FederationPeerHint only if the decision selects it)"
|
|
||||||
contains: "lightning"
|
|
||||||
key_links:
|
|
||||||
- from: core/archipelago/src/federation/sync.rs
|
|
||||||
to: core/archipelago/src/federation/types.rs
|
|
||||||
via: "build_local_state populates the Lightning field from this node's lnd.getinfo identity"
|
|
||||||
pattern: "lightning"
|
|
||||||
- from: core/archipelago/src/federation/storage.rs
|
|
||||||
to: core/archipelago/src/api/rpc/federation/handlers.rs
|
|
||||||
via: "update_node_state persists the peer's Lightning URI; federation.list-nodes emits it"
|
|
||||||
pattern: "lightning"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Carry a federated peer's Lightning URI over the federation sync payload, so the channel-open picker
|
|
||||||
can list **trusted nodes by hostname** and open a channel with one click.
|
|
||||||
|
|
||||||
Purpose: FED-05's primary list. RESEARCH.md Pitfall 5 is blunt: building the picker before the
|
|
||||||
backend can supply a federated peer's Lightning pubkey/host produces a UI that lists names and has
|
|
||||||
nothing to pass to `lnd.openchannel`. `NodeStateSnapshot` — the payload `federation.get-state` and
|
|
||||||
sync exchange — carries no Lightning fields at all today.
|
|
||||||
Output: the sync payload extended, the peer's URI persisted and emitted by `federation.list-nodes`,
|
|
||||||
and the sharing default explicitly chosen by the operator rather than assumed.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md
|
|
||||||
@core/archipelago/src/federation/types.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| Lightning identity field(s) on `NodeStateSnapshot` | new optional serde-default field(s); exact names fixed by the Task 1 decision | `core/archipelago/src/federation/types.rs` |
|
|
||||||
| Lightning identity field(s) on `FederationPeerHint` | added **only if** the decision selects transitive sharing | same |
|
|
||||||
| `FederatedNode.lightning_uri: Option<String>` | new persisted field on the local node record | same |
|
|
||||||
| `build_local_state` Lightning parameter | changed fn signature in the federation sync builder | `core/archipelago/src/federation/sync.rs` |
|
|
||||||
| `share_lightning_uri` | server setting + its accessor, **only if** the decision selects opt-in gating | `core/archipelago/src/api/rpc/federation/handlers.rs` (+ server info) |
|
|
||||||
| `lightning_uri` on `federation.list-nodes` | new response field | `core/archipelago/src/api/rpc/federation/handlers.rs` |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="checkpoint:decision" gate="blocking">
|
|
||||||
<name>Task 1: Decide the Lightning field shape and sharing default on the federation sync payload</name>
|
|
||||||
<decision>What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in?</decision>
|
|
||||||
<context>
|
|
||||||
This writes a new field into `NodeStateSnapshot`, the payload every federated node exchanges on
|
|
||||||
every sync. Once a release carrying it reaches the fleet, deployed peers parse that shape — a
|
|
||||||
later change to the field name, the split, or the sharing scope needs a coordinated fleet
|
|
||||||
upgrade plus a cleanup of URIs already cached in every peer's `nodes.json`. That is a one-way
|
|
||||||
door, and the sources disagree about which way to walk through it:
|
|
||||||
|
|
||||||
- `01-CONTEXT.md` records "a federated peer's Lightning URI rides the federation sync payload
|
|
||||||
**by default** — federation trust is already bilateral and explicit", and marks this
|
|
||||||
**Claude's discretion, revisable** — not locked.
|
|
||||||
- `01-RESEARCH.md` Open Question 3 recommends the opposite: follow the `shared_location`
|
|
||||||
precedent (opt-in, default off) "since exposing a payment channel target more broadly than
|
|
||||||
intended has real-money implications."
|
|
||||||
|
|
||||||
A second, related question rides along: `NodeStateSnapshot.federated_peers` carries a
|
|
||||||
`FederationPeerHint` for each of this node's trusted peers, used for transitive discovery. If the
|
|
||||||
Lightning field goes on the hint too, then Alice syncing with Bob learns Bob's *peers'* Lightning
|
|
||||||
URIs — a payment endpoint reaching a party that node never federated with. Options B and C below
|
|
||||||
keep the field off the hint; only choose otherwise deliberately.
|
|
||||||
</context>
|
|
||||||
<options>
|
|
||||||
<option id="option-a">
|
|
||||||
<name>Single `lightning_uri` on the snapshot AND on the peer hint, shared by default</name>
|
|
||||||
<pros>Widest picker coverage — a peer-of-a-peer's URI is available without an extra sync hop; simplest single field; matches CONTEXT.md's default-on stance</pros>
|
|
||||||
<cons>Sends a payment endpoint to nodes the operator never federated with, which the plan's own privacy prohibition forbids; hardest to walk back once cached across the fleet</cons>
|
|
||||||
</option>
|
|
||||||
<option id="option-b">
|
|
||||||
<name>Single `lightning_uri` on the snapshot only, shared by default with direct federated peers (CONTEXT.md's stated default, narrowed)</name>
|
|
||||||
<pros>Implements CONTEXT.md's recorded discretion default; bilateral federation trust is already explicit, so no new consent surface is needed; one field, one hop, no transitive leak; ships the picker with real data on day one</pros>
|
|
||||||
<cons>Every existing federated pair starts sharing a payment endpoint on the OTA that carries it, with no per-operator prompt; reversing later means shipping an opt-out and waiting for peers to re-sync</cons>
|
|
||||||
</option>
|
|
||||||
<option id="option-c">
|
|
||||||
<name>Single `lightning_uri` on the snapshot only, gated behind an explicit opt-in setting defaulting off (RESEARCH.md Open Question 3)</name>
|
|
||||||
<pros>Mirrors the proven `shared_location` pattern exactly; no operator starts sharing a payment endpoint without acting; safest given real-money implications; the field itself stays additive so flipping the default later is a one-line change</pros>
|
|
||||||
<cons>The trusted-node picker is empty until both sides opt in, so the FED-05 flow needs a discoverable "turn on Lightning sharing" path or it looks broken; more surface to build in this plan</cons>
|
|
||||||
</option>
|
|
||||||
</options>
|
|
||||||
<resume-signal>Select: option-a, option-b, or option-c. If you pick option-c, also say where the toggle lives (a new row in the existing federation settings surface is the default assumption).</resume-signal>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes</name>
|
|
||||||
<reversibility rating="one-way">This adds a field to `NodeStateSnapshot`, the wire payload every
|
|
||||||
fleet node parses on every sync; after the OTA carrying it, changing the field's name, split, or
|
|
||||||
sharing scope requires a coordinated fleet upgrade and a cleanup of URIs already cached in peers'
|
|
||||||
node files.</reversibility>
|
|
||||||
<precondition>`lnd.getinfo` returns `identity_pubkey` and `uris` (delivered by plan 01-04, Task 1) — confirm by reading `core/archipelago/src/api/rpc/lnd/info.rs` for both field names before starting.</precondition>
|
|
||||||
<files>core/archipelago/src/federation/types.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/types.rs` lines 104-146 — the `shared_location` (`lat`/`lon`)
|
|
||||||
opt-in field pair with its doc comment explaining absent-vs-null, and the `FederationPeerHint`
|
|
||||||
struct with its `pubkey`/`onion` split. These are the exact patterns to mirror.
|
|
||||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` around lines 470-485 — the
|
|
||||||
`shared_location` gating block (`if data.server_info.share_location { ... } else { None }`)
|
|
||||||
and how it is threaded into `federation::build_local_state`.
|
|
||||||
- `core/archipelago/src/federation/sync.rs` around lines 225-265 — `build_local_state`'s
|
|
||||||
signature and where `shared_location` is mapped into the snapshot at construction time.
|
|
||||||
- `core/archipelago/src/federation/storage.rs` `update_node_state` as left by plan 01-05,
|
|
||||||
including the monotonicity guard and the `fips_npub` exemption comment — the new field follows
|
|
||||||
the same "learn from the peer's snapshot" treatment.
|
|
||||||
- `core/archipelago/src/api/rpc/lnd/info.rs` as left by plan 01-04 — the `identity_pubkey` /
|
|
||||||
`uris` field names and the 66-hex validation helper to reuse.
|
|
||||||
- `core/archipelago/src/api/rpc/lnd/channels.rs` `handle_lnd_openchannel` (from L238) — the
|
|
||||||
exact URI/pubkey/address parsing the picker will feed, so the persisted format matches what
|
|
||||||
that handler accepts.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- `build_local_state` called with a Lightning URI puts it on the produced snapshot; called
|
|
||||||
without one produces a snapshot with the field absent (not an empty string).
|
|
||||||
- A snapshot deserialized from a payload that has no Lightning field succeeds with the field
|
|
||||||
`None` — an older peer syncs fine.
|
|
||||||
- `update_node_state` with a snapshot carrying a well-formed URI persists it onto the
|
|
||||||
`FederatedNode`; with a malformed URI it leaves any previously stored value untouched.
|
|
||||||
- A snapshot rejected by the plan-01-05 monotonicity guard does not clear an already-known URI.
|
|
||||||
- `federation.list-nodes` emits `lightning_uri` for a node that has one and omits it otherwise.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Implement exactly the option selected in Task 1 — do not substitute a different shape, and do not
|
|
||||||
add the field to `FederationPeerHint` unless option-a was chosen. Record the chosen option id in
|
|
||||||
the SUMMARY.
|
|
||||||
|
|
||||||
Write the tests first and confirm they fail.
|
|
||||||
|
|
||||||
Add the Lightning field(s) to `NodeStateSnapshot` with `#[serde(default)]` and a doc comment that
|
|
||||||
states the sharing rule chosen in Task 1 and explicitly notes where it differs from the
|
|
||||||
`shared_location` analog directly above it. Add `#[serde(default)] pub lightning_uri: Option<String>`
|
|
||||||
to `FederatedNode` for the locally-persisted peer value, and update the `make_node` test helper
|
|
||||||
so the struct literal still compiles.
|
|
||||||
|
|
||||||
Thread the value into `build_local_state` the same way `shared_location` is threaded: an added
|
|
||||||
parameter, mapped into the snapshot at construction. At the `handlers.rs` call site, source it
|
|
||||||
from this node's own `lnd.getinfo` identity (prefer the first entry of `uris`; fall back to
|
|
||||||
composing `identity_pubkey` with the node's reachable host when `uris` is empty), gated per the
|
|
||||||
Task 1 decision. An LND that is down or has no URI yields `None`, never an empty string and never
|
|
||||||
a fabricated address.
|
|
||||||
|
|
||||||
In `update_node_state`, learn the peer's URI from the snapshot: validate the shape before storing
|
|
||||||
(the pubkey part is 66 hexadecimal characters; the `@host[:port]` remainder is optional, matching
|
|
||||||
what `handle_lnd_openchannel` accepts), and on a malformed value log at `debug!` and leave the
|
|
||||||
prior value alone.
|
|
||||||
|
|
||||||
In `handle_federation_list_nodes`, emit `lightning_uri` with the same `if let Some(...)`
|
|
||||||
conditional-insert pattern the other optional fields use.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation` exits 0 with the five behaviors above present as named cases.
|
|
||||||
- `grep -c 'lightning' core/archipelago/src/federation/types.rs` is at least 3.
|
|
||||||
- `grep -c 'serde(default)' core/archipelago/src/federation/types.rs` increased by at least 2 relative to the pre-change file.
|
|
||||||
- A round-trip test proves back-compat in both directions: a snapshot JSON with no Lightning key deserializes to `None`, and a snapshot serialized with the field deserializes cleanly after being stripped of unknown keys.
|
|
||||||
- `grep -c 'lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
|
|
||||||
- If and only if option-a was selected: `grep -c 'lightning' core/archipelago/src/federation/types.rs` includes an occurrence inside the `FederationPeerHint` struct. Otherwise `FederationPeerHint` has none — assert this either way and state which in the SUMMARY.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Make the sharing state visible and reversible</name>
|
|
||||||
<files>core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/federation/sync.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- The Task 1 decision as recorded in the Task 2 SUMMARY notes.
|
|
||||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` — the `share_location` server-info flag
|
|
||||||
and the `server.set-location` RPC that toggles it, for the accessor + persistence pattern.
|
|
||||||
- `core/archipelago/src/federation/sync.rs` — `build_local_state`'s tests (from L335) for the
|
|
||||||
assertion style.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Under option-c: add the `share_lightning_uri` server setting with a default of off, an RPC to
|
|
||||||
read and set it following the `server.set-location` shape, and make `build_local_state`'s
|
|
||||||
Lightning parameter `None` whenever the flag is off. Add tests: flag off produces a snapshot with
|
|
||||||
no Lightning field even when LND has one; flag on produces it; toggling the flag off then
|
|
||||||
re-syncing produces a snapshot without it.
|
|
||||||
|
|
||||||
Under option-a or option-b: add a read-only surface reporting the current sharing state and the
|
|
||||||
URI actually being shared, so the operator can see what is going out; and make the outbound value
|
|
||||||
`None` whenever this node's own Lightning is not installed or not reachable. Add tests: no LND
|
|
||||||
produces no Lightning field; a present LND produces the URI; the reported state matches what
|
|
||||||
`build_local_state` actually emits.
|
|
||||||
|
|
||||||
In both cases, add a test asserting a third-party peer's Lightning URI is never re-exported in
|
|
||||||
this node's own outbound peer hints — build a local state while holding a peer whose URI is
|
|
||||||
known, serialize it, and assert that URI string does not appear in the outbound payload's peer
|
|
||||||
hint section. This test is the mechanical form of this plan's privacy prohibition and must exist
|
|
||||||
regardless of which option was chosen.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago federation::sync</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago federation::sync` exits 0.
|
|
||||||
- A test named for the third-party-URI-not-re-exported behavior exists and passes; temporarily injecting the peer URI into the outbound hint makes it fail (fail-first proof recorded in the SUMMARY).
|
|
||||||
- Under option-c only: `grep -c 'share_lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 2.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| this node → federated peer (outbound snapshot) | This node's payment endpoint crosses to a remote party |
|
|
||||||
| federated peer → this node (inbound snapshot) | A remote party's claimed payment endpoint is persisted and later fed to `lnd.openchannel` |
|
|
||||||
| transitive peer hint | A third party's identity data can ride this node's outbound payload to a party it never federated with |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-21 | Information Disclosure | this node's Lightning payment endpoint reaching a non-federated party | high | mitigate | The Task 1 decision fixes the sharing scope explicitly; Task 3 adds the test proving a third-party URI is never re-exported in outbound peer hints, plus a fail-first proof |
|
|
||||||
| T-01-22 | Spoofing | a peer advertising a Lightning URI it does not control, redirecting a channel open and its funds | high | mitigate | The snapshot arrives over the existing ed25519-signature-verified federation path (unchanged); the URI is bound to that verified peer record and validated for shape before persistence. Not re-implemented here — the existing `identity::NodeIdentity::verify` path is reused, per RESEARCH.md V6 |
|
|
||||||
| T-01-23 | Tampering | a malformed or oversized URI corrupting the persisted node record | high | mitigate | 66-hex pubkey validation before persist; malformed input leaves the prior value untouched, with a test |
|
|
||||||
| T-01-24 | Tampering | a replayed older snapshot clearing a known Lightning URI | medium | mitigate | The plan-01-05 monotonicity guard rejects strictly-older snapshots; a test asserts a rejected snapshot does not clear the URI |
|
|
||||||
| T-01-25 | Repudiation | the operator unable to tell what identity their node is sharing | medium | mitigate | Task 3 adds the visible sharing state (a setting under option-c, a read-only report otherwise) |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago` — green.
|
|
||||||
- Back-compat round-trip proven in both directions against a payload lacking the new field.
|
|
||||||
- The chosen option id is recorded in the SUMMARY, together with the fail-first proof for the no-re-export test.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- The Lightning identity field exists on the federation sync payload in exactly the shape the operator chose, additively and back-compatibly.
|
|
||||||
- A trusted peer's URI is validated, persisted, and emitted by `federation.list-nodes`.
|
|
||||||
- A third party's URI provably never leaves this node in its own peer hints.
|
|
||||||
- The current sharing state is visible to the operator.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 07
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on: ["01-04"]
|
|
||||||
files_modified:
|
|
||||||
- core/archipelago/src/mesh/message_types.rs
|
|
||||||
- core/archipelago/src/mesh/listener/dispatch.rs
|
|
||||||
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
- core/archipelago/src/api/rpc/dispatcher.rs
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-05]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message"
|
|
||||||
- "A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path"
|
|
||||||
- "Sending a channel-open request requires an explicit target peer — there is no broadcast form"
|
|
||||||
- "A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message"
|
|
||||||
- "Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)"
|
|
||||||
- "A request is never rendered or reported as an opened or funded channel — it carries no channel state"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count"
|
|
||||||
category: transparency
|
|
||||||
- statement: "Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision"
|
|
||||||
category: safety
|
|
||||||
artifacts:
|
|
||||||
- path: core/archipelago/src/mesh/message_types.rs
|
|
||||||
provides: "ChannelOpenRequest typed message + payload"
|
|
||||||
contains: "ChannelOpenRequest"
|
|
||||||
key_links:
|
|
||||||
- from: core/archipelago/src/api/rpc/dispatcher.rs
|
|
||||||
to: core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
via: "mesh.request-channel match arm"
|
|
||||||
pattern: "mesh.request-channel"
|
|
||||||
- from: core/archipelago/src/mesh/listener/dispatch.rs
|
|
||||||
to: core/archipelago/src/mesh/types.rs
|
|
||||||
via: "inbound ChannelOpenRequest is stored as a MeshMessage with its typed payload"
|
|
||||||
pattern: "ChannelOpenRequest"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Give a meshed Lightning peer a way to be *asked* for a channel: a typed mesh message carrying the
|
|
||||||
requester's Lightning URI and an optional note, sent to one chosen peer and rendered in the
|
|
||||||
recipient's conversation.
|
|
||||||
|
|
||||||
Purpose: FED-05's second list. CONTEXT.md locks the semantics — meshed peers with Lightning
|
|
||||||
installed are nodes you "request to open a channel with", not nodes you open against directly,
|
|
||||||
because mesh peers are not bilaterally trusted the way federated nodes are. 01-UI-SPEC.md fixes the
|
|
||||||
UI verb ("Request Channel", reusing `PeerRequestModal.vue`'s message field and busy states).
|
|
||||||
PATTERNS.md records that the send/receive shape for this is `typed_messages.rs`'s existing
|
|
||||||
reaction/reply family — a struct-per-message-type serialized into the standard envelope — and that
|
|
||||||
no capability/request mechanism exists yet to extend.
|
|
||||||
Output: `MeshMessageType::ChannelOpenRequest`, its payload, an inbound arm that stores it as a
|
|
||||||
conversation message, and a `mesh.request-channel` RPC.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md
|
|
||||||
@core/archipelago/src/mesh/message_types.rs
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `MeshMessageType::ChannelOpenRequest = 27` (label `channel_open_request`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` |
|
|
||||||
| `ChannelOpenRequestPayload { uri, amount_sats, message }` | new CBOR payload struct | same |
|
|
||||||
| inbound `ChannelOpenRequest` arm | listener dispatch arm storing the request as a `MeshMessage` | `core/archipelago/src/mesh/listener/dispatch.rs` |
|
|
||||||
| `handle_mesh_request_channel` | new RPC handler (`mesh.request-channel`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` |
|
|
||||||
| `mesh.request-channel` | dispatcher match arm | `core/archipelago/src/api/rpc/dispatcher.rs` |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation</name>
|
|
||||||
<reversibility rating="costly">`MeshMessageType` is a radio wire format read by every fleet node
|
|
||||||
after the next OTA; the discriminant and payload shape become externally visible, so a later change
|
|
||||||
needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default
|
|
||||||
optional payload fields, so older nodes ignore it rather than erroring.</reversibility>
|
|
||||||
<precondition>`MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number.</precondition>
|
|
||||||
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/listener/dispatch.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/mesh/message_types.rs` — the enum and the four places every variant is
|
|
||||||
registered (`enum`, `from_u8`, `from_label`, `label`), `InvoicePayload` (from L413) as the
|
|
||||||
closest payload analog (it also carries a payment-ish string plus an optional amount), and the
|
|
||||||
`TypedEnvelope` doc comment on `compact_bytes` and LoRa frame size.
|
|
||||||
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-760 — `handle_mesh_send_reply`
|
|
||||||
and `handle_mesh_send_reaction`: param extraction, target-peer resolution, sequence-number
|
|
||||||
allocation, `TypedEnvelope::new(...).with_seq(seq)`, and the send call.
|
|
||||||
- `core/archipelago/src/mesh/listener/dispatch.rs` lines 430-500 — the `Reaction` and `Presence`
|
|
||||||
inbound arms, and how an inbound typed message is turned into a stored `MeshMessage` with
|
|
||||||
`message_type` and `typed_payload` set.
|
|
||||||
- `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` fields the stored
|
|
||||||
request must populate (`plaintext` is the human-readable fallback shown in list views).
|
|
||||||
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line registration convention.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract row
|
|
||||||
for "Primary CTA — meshed Lightning peer", which fixes what the UI in plan 01-08 will send.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- `MeshMessageType` round-trips the new variant through `from_u8`, `from_label`, and `label`.
|
|
||||||
- `handle_mesh_request_channel` with no target peer in params returns an error; with an unknown
|
|
||||||
target peer it returns an error naming the peer.
|
|
||||||
- `handle_mesh_request_channel` with a target sends exactly one envelope of the new type, whose
|
|
||||||
payload carries this node's own Lightning URI and the caller's optional amount and message.
|
|
||||||
- Two consecutive calls to the same target allocate two different sequence numbers.
|
|
||||||
- An inbound envelope of the new type with a well-formed URI is stored as a `MeshMessage` whose
|
|
||||||
`message_type` is the new label and whose `typed_payload` carries the request fields.
|
|
||||||
- An inbound envelope whose payload URI is malformed stores nothing and logs a warning.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write the tests first and confirm they fail.
|
|
||||||
|
|
||||||
Add `ChannelOpenRequest = 27` to `MeshMessageType` (confirm 27 is unused first) with a doc comment
|
|
||||||
stating that this is a *request*, that it carries no channel state, and that receiving one never
|
|
||||||
causes the node to act. Register it in `from_u8`, `from_label` ("channel_open_request"), and
|
|
||||||
`label`.
|
|
||||||
|
|
||||||
Add `ChannelOpenRequestPayload` beside the other payload structs: a required `uri: String` (the
|
|
||||||
requester's own `pubkey@host:port`), plus `#[serde(default)] amount_sats: Option<u64>` and
|
|
||||||
`#[serde(default)] message: Option<String>`. Bound the optional message length before send so a
|
|
||||||
long note cannot blow past the LoRa framing budget the `TypedEnvelope` doc comment warns about;
|
|
||||||
truncate at the send side rather than rejecting, and say so in a comment.
|
|
||||||
|
|
||||||
Add `handle_mesh_request_channel` following the `handle_mesh_send_reply` shape: require a target
|
|
||||||
peer identifier in params and error without one (there is no broadcast form); read this node's
|
|
||||||
own Lightning URI via the identity path plan 01-04 added to `lnd.getinfo`, and error with a clear
|
|
||||||
message when it is unavailable rather than sending an empty request; build the payload, wrap it in
|
|
||||||
a `TypedEnvelope` with a freshly allocated sequence number, and send it to that peer only.
|
|
||||||
Register it in the dispatcher as `"mesh.request-channel"`.
|
|
||||||
|
|
||||||
Add the inbound arm in `dispatch.rs` mirroring the `Reaction` arm: deserialize the payload,
|
|
||||||
validate the URI shape (66-hex pubkey part, optional `@host[:port]`), and on success store a
|
|
||||||
`MeshMessage` with the new label as `message_type`, the payload as `typed_payload`, and a
|
|
||||||
human-readable `plaintext` summary naming the requester and the requested amount when present.
|
|
||||||
On a malformed URI, log at `warn!` and store nothing.
|
|
||||||
|
|
||||||
Do not add any code path that connects to, opens a channel with, or funds the requester on
|
|
||||||
receipt. The inbound arm's only effect is storing a message.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh` exits 0 with the six behaviors above present as named cases.
|
|
||||||
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/message_types.rs` is at least 5.
|
|
||||||
- `grep -c 'channel_open_request' core/archipelago/src/mesh/message_types.rs` is at least 2.
|
|
||||||
- `grep -c '"mesh.request-channel"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
|
|
||||||
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
|
|
||||||
- The inbound arm contains no call to any `openchannel`, `connectpeer`, or send-funds path — verified by reading the arm and recorded in the SUMMARY.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- The SUMMARY records the pre-implementation failing test output.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Harden the request path against duplicates, oversize, and misuse</name>
|
|
||||||
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` as left by Task 1, plus the
|
|
||||||
`handle_mesh_send_content_inline` size-tier logic for how this codebase bounds payload size
|
|
||||||
before a send.
|
|
||||||
- `core/archipelago/src/mesh/outbox.rs` — whether an outbound send is queued and retried, so the
|
|
||||||
duplicate-suppression window is placed where it will actually see both attempts.
|
|
||||||
- `core/archipelago/src/mesh/types.rs` — `MeshPeer`'s authenticating-key accessor doc comment
|
|
||||||
(never use the firmware routing key for authentication).
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the
|
|
||||||
same target peer within a bounded interval returns a distinct, non-error result reporting that a
|
|
||||||
request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also
|
|
||||||
disables its button while a send is in flight, but a backend guard is what actually stops a
|
|
||||||
double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated
|
|
||||||
by more than the window must both go out — the window suppresses accidental duplicates, not
|
|
||||||
legitimate repeat requests. Add tests for both sides of the window.
|
|
||||||
|
|
||||||
Bound the inbound side too: reject an inbound payload whose message field exceeds the same
|
|
||||||
length bound the send side truncates at, and reject an `amount_sats` outside the range
|
|
||||||
`handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can
|
|
||||||
never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds
|
|
||||||
rather than restating them from memory.
|
|
||||||
|
|
||||||
Attribute the stored inbound message to the peer's authenticating identity key, not the firmware
|
|
||||||
routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be
|
|
||||||
from a trusted peer must be attributable.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago mesh api::rpc::mesh</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case.
|
|
||||||
- A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them.
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| radio peer → typed-envelope decode → stored message | Untrusted RF input becomes a conversation entry naming a payment endpoint |
|
|
||||||
| operator RPC → outbound request | An operator action discloses this node's payment endpoint to a chosen mesh peer |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-26 | Spoofing | a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI | high | mitigate | The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human |
|
|
||||||
| T-01-27 | Elevation of Privilege | a received request causing an automatic channel open or fund movement | high | mitigate | The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call |
|
|
||||||
| T-01-28 | Denial of Service | request flooding filling a peer's conversation or saturating a LoRa link | high | mitigate | Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2) |
|
|
||||||
| T-01-29 | Information Disclosure | broadcasting this node's payment endpoint to every contact in range | high | mitigate | A target peer is required; the handler errors without one and there is no broadcast form |
|
|
||||||
| T-01-30 | Tampering | an oversize payload fragmenting into unreassemblable LoRa chunks | medium | mitigate | The message field is truncated at the send side against the framing budget the `TypedEnvelope` doc comment describes; inbound oversize is rejected |
|
|
||||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago` — green.
|
|
||||||
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
|
|
||||||
- The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- A new typed mesh message carries a channel-open request to one named peer.
|
|
||||||
- Receiving one stores a conversation message and does nothing else.
|
|
||||||
- Duplicates within a short window are suppressed; legitimate repeats are not.
|
|
||||||
- Malformed URIs, oversize notes, and out-of-range amounts are refused.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 08
|
|
||||||
type: execute
|
|
||||||
wave: 4
|
|
||||||
depends_on: ["01-02", "01-06", "01-07"]
|
|
||||||
files_modified:
|
|
||||||
- neode-ui/src/components/LightningChannelModal.vue
|
|
||||||
- neode-ui/src/components/LightningChannelsPanel.vue
|
|
||||||
- neode-ui/src/api/rpc-client.ts
|
|
||||||
- neode-ui/mock-backend.js
|
|
||||||
- neode-ui/src/components/__tests__/LightningChannelModal.test.ts
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-05]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A user can copy their own node's Lightning URI from the channel-open surface; the copy button label flips to Copied! for about two seconds"
|
|
||||||
- "The displayed own-node URI truncates to its container with the full value in a title tooltip, and the full untruncated value is what reaches the clipboard"
|
|
||||||
- "Trusted federated nodes that advertise Lightning are listed by hostname with a one-click Open Channel action"
|
|
||||||
- "Meshed peers that have Lightning installed are listed separately with a Request Channel action, never a direct open — they are not bilaterally trusted"
|
|
||||||
- "A peer that is both a trusted federated node and a meshed Lightning peer appears exactly once, in the trusted list (FED-05 adjacency edge)"
|
|
||||||
- "Both picker lists render in a deterministic order that does not reshuffle between refreshes (FED-05 ordering edge)"
|
|
||||||
- "When both lists are empty a single shared empty state renders once — not one per column"
|
|
||||||
- "Both lists show the house loading treatment while fetching and the house error row on failure, matching the existing federation node list and Lightning channels panel conventions"
|
|
||||||
- "Each list row shows the node name with truncation and a title tooltip, its trust badge, and its transport badge, mirroring the existing federation node row"
|
|
||||||
- "Clicking Open Channel twice, or opening two channels to the same peer at once, results in one open attempt — the action is disabled while a request is in flight (FED-05 concurrency edge)"
|
|
||||||
- "A manually pasted pubkey with no host still works, falling back to the address-less open path the Lightning channels panel already relies on"
|
|
||||||
- "The manual-paste field is reached through a de-emphasised Paste URI Manually entry point below both lists, not as a third equal-weight column"
|
|
||||||
- "The modal renders through the house modal shell so its backdrop covers the full screen and a click outside closes it"
|
|
||||||
- "The request flow reuses the existing peer-request modal pattern — optional message field, Send Request submit, Sending… busy label"
|
|
||||||
- statement: "When both lists are empty the shared empty state renders exactly once rather than once per list"
|
|
||||||
verification: backstop
|
|
||||||
- statement: "A manually pasted URI that is not in pubkey@host:port form is rejected client-side with a format message before any open call is made"
|
|
||||||
verification: backstop
|
|
||||||
prohibitions:
|
|
||||||
- statement: "A channel-open request sent to a meshed peer MUST NOT be displayed as an open, pending-funding, or connected channel anywhere in the UI — until the recipient acts, it is a sent request and nothing more"
|
|
||||||
category: transparency
|
|
||||||
- statement: "The picker MUST NOT present a meshed peer's advertised URI with the same visual authority as a bilaterally-trusted federated node — the two lists stay visually distinct and the meshed action stays a request, so a user cannot mistake an unverified advertisement for a trusted target"
|
|
||||||
category: safety
|
|
||||||
artifacts:
|
|
||||||
- path: neode-ui/src/components/LightningChannelModal.vue
|
|
||||||
provides: "Own-URI share, trusted-node picker, meshed-peer request picker, manual-paste fallback"
|
|
||||||
min_lines: 150
|
|
||||||
- path: neode-ui/src/components/__tests__/LightningChannelModal.test.ts
|
|
||||||
provides: "State coverage for empty, loading, error, populated, dedup, ordering, and double-click"
|
|
||||||
min_lines: 60
|
|
||||||
key_links:
|
|
||||||
- from: neode-ui/src/components/LightningChannelsPanel.vue
|
|
||||||
to: neode-ui/src/components/LightningChannelModal.vue
|
|
||||||
via: "the panel's existing Open Channel button opens the new picker modal"
|
|
||||||
pattern: "LightningChannelModal"
|
|
||||||
- from: neode-ui/src/components/LightningChannelModal.vue
|
|
||||||
to: neode-ui/src/api/rpc-client.ts
|
|
||||||
via: "federation.list-nodes, mesh.lightning-peers, lnd.getinfo, lnd.openchannel, mesh.request-channel"
|
|
||||||
pattern: "lightning-peers"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Make channel opening between nodes first-class UI: share your node's Lightning URI, open a channel
|
|
||||||
with a trusted federated node in one click, and request a channel from a meshed peer that has
|
|
||||||
Lightning installed.
|
|
||||||
|
|
||||||
Purpose: FED-05's user-facing half, with scope locked in CONTEXT.md (the second list is *meshed peer
|
|
||||||
nodes that have Lightning installed* — not `lnd listpeers`, not a curated directory, not a live
|
|
||||||
LN-graph query) and its visuals fixed by 01-UI-SPEC.md (copy, colours, spacing, the shared empty
|
|
||||||
state, the de-emphasised manual-paste fallback, and the hard modal rule).
|
|
||||||
Output: a new picker modal built from the house design system, reached from the Lightning panel's
|
|
||||||
existing Open Channel button, working against the demo and against archi-dev.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md
|
|
||||||
@neode-ui/src/components/BaseModal.vue
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `LightningChannelModal.vue` | new Vue component (picker modal) | `neode-ui/src/components/LightningChannelModal.vue` |
|
|
||||||
| `meshLightningPeers()`, `requestChannel()`, `sendLightningInfo()` | new rpc-client wrappers | `neode-ui/src/api/rpc-client.ts` |
|
|
||||||
| `mesh.lightning-peers`, `mesh.send-lightning-info`, `mesh.request-channel` | new mock RPC cases | `neode-ui/mock-backend.js` |
|
|
||||||
| `identity_pubkey` / `uris` on the mock `lnd.getinfo` result | extended mock response | same |
|
|
||||||
| `lightning_uri` on the mock `federation.list-nodes` nodes | extended mock response | same |
|
|
||||||
| `LightningChannelModal.test.ts` | new vitest suite | `neode-ui/src/components/__tests__/LightningChannelModal.test.ts` |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer" tdd="true">
|
|
||||||
<name>Task 1: End-to-end — open a channel with a trusted federated node in one click</name>
|
|
||||||
<precondition>`federation.list-nodes` emits `lightning_uri` (plan 01-06) and `mesh.lightning-peers` is registered in the dispatcher (plan 01-04) — confirm both by grepping `core/archipelago/src/api/rpc/federation/handlers.rs` and `core/archipelago/src/api/rpc/dispatcher.rs` before starting.</precondition>
|
|
||||||
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/api/rpc-client.ts, neode-ui/mock-backend.js, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/src/components/LightningChannelsPanel.vue</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/src/components/BaseModal.vue` — the whole file. It already wraps `Teleport to="body"`,
|
|
||||||
the full-screen `bg-black/60 backdrop-blur-md` backdrop, `@click.self` close, the pinned
|
|
||||||
`text-xl font-semibold` title, and the scrolling body. The new modal MUST use it; never nest a
|
|
||||||
modal inside a transform-affected ancestor.
|
|
||||||
- `neode-ui/src/components/LightningChannelsPanel.vue` lines 246-360 (its current bespoke
|
|
||||||
open-channel modal, its `openError` ref, `isStartupNotice()` amber-vs-red distinction, and the
|
|
||||||
fee-preset block) and lines 505-630 (`showOpenModal`, `defaultOpenForm()`, `openForm`,
|
|
||||||
`openingChannel`, and the validate-before-RPC sequence including the 20,000-sat minimum and the
|
|
||||||
`pubkey@host:port` split with an optional address).
|
|
||||||
- `neode-ui/src/views/federation/NodeList.vue` lines 40-140 and 155-190 — the row layout to
|
|
||||||
mirror (truncated name with `:title`, transport badge, trust badge, action button), the
|
|
||||||
`trustedNodes` / `peerNodes` computed filters, and the "Loading nodes..." row.
|
|
||||||
- `neode-ui/src/api/rpc-client.ts` lines 795-850 — the one-line
|
|
||||||
`this.call({ method: '<ns>.<verb>', params })` wrapper convention and the existing
|
|
||||||
`federation.list-nodes` wrapper.
|
|
||||||
- `neode-ui/mock-backend.js` — the `lnd.getinfo`, `lnd.openchannel`, and `federation.list-nodes`
|
|
||||||
cases, and the parity harness added by plan 01-02.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — Design System, Spacing,
|
|
||||||
Typography, Color, and the full Copywriting Contract. Every string in this modal comes from
|
|
||||||
that table verbatim.
|
|
||||||
</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Mounted with a node list containing one trusted node that has a Lightning URI and one that does
|
|
||||||
not, the trusted list renders exactly one row.
|
|
||||||
- The row shows the node name (truncated, with a `title`), its trust badge, its transport badge,
|
|
||||||
and an Open Channel button.
|
|
||||||
- Clicking Open Channel calls the open RPC once with that node's URI; clicking it twice in
|
|
||||||
immediate succession still results in exactly one call, and the button is disabled while in
|
|
||||||
flight.
|
|
||||||
- While the node list is loading, the loading treatment renders and no empty state renders.
|
|
||||||
- When the node fetch rejects, the error row renders with the contract's error copy.
|
|
||||||
- The modal root renders through the house modal shell, so the backdrop is a full-screen sibling
|
|
||||||
of the card rather than a child of a transformed ancestor.
|
|
||||||
</behavior>
|
|
||||||
<action>
|
|
||||||
Write the test file first and confirm it fails.
|
|
||||||
|
|
||||||
Add rpc-client wrappers for `mesh.lightning-peers`, `mesh.send-lightning-info`, and
|
|
||||||
`mesh.request-channel` following the existing one-line convention. Extend the mock backend so the
|
|
||||||
demo answers all three, so `lnd.getinfo` returns an `identity_pubkey` and a `uris` array, and so
|
|
||||||
`federation.list-nodes` nodes carry `lightning_uri` — mirroring the real handlers per the mock's
|
|
||||||
established "cite the daemon source" comment convention. The plan-01-02 parity harness must stay
|
|
||||||
green.
|
|
||||||
|
|
||||||
Create `LightningChannelModal.vue` using `BaseModal` as its shell, title "Open Lightning Channel".
|
|
||||||
In this task implement the trusted-node section only: fetch the federated node list, keep nodes
|
|
||||||
whose trust level is trusted AND which have a Lightning URI, sort by display name with a stable
|
|
||||||
tiebreak so the order does not shuffle between refreshes, and render each as a row mirroring the
|
|
||||||
federation node row — truncated name with a `title` tooltip, the transport badge reusing
|
|
||||||
NodeList's existing FIPS/Tor logic, the trust badge, and an Open Channel button on the right.
|
|
||||||
|
|
||||||
Wire Open Channel to the existing open-channel RPC, reusing the panel's proven sequence: validate
|
|
||||||
before calling, keep the 20,000-sat minimum, split the URI into pubkey and optional address, and
|
|
||||||
reuse the `openError` ref plus the `isStartupNotice()` amber-vs-red distinction rather than
|
|
||||||
inventing a new error idiom. Guard against double submission with an in-flight flag keyed to the
|
|
||||||
target so the button is disabled and a second click is a no-op.
|
|
||||||
|
|
||||||
Point the Lightning panel's **existing** Open Channel button at this modal instead of its bespoke
|
|
||||||
one. Do not add a new nav entry, route, card, or dashboard tile — the user places new entry
|
|
||||||
points, this plan only upgrades the one that already exists. Leave the panel's channel list,
|
|
||||||
close-channel flow, and fee presets untouched.
|
|
||||||
|
|
||||||
Follow the UI-SPEC tables exactly: spacing on the 4px grid, the two-weight typography scale,
|
|
||||||
accent orange reserved for the primary action buttons, the bolt icon path already used elsewhere
|
|
||||||
in the app, and the copy strings verbatim.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && test -f src/components/__tests__/LightningChannelModal.test.ts && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- The test file exists and `npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with all six behaviors present as named cases (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
|
||||||
- A test asserts two immediate clicks produce exactly one open call.
|
|
||||||
- `grep -c 'BaseModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (import + usage).
|
|
||||||
- `grep -c 'Open Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1 and the copy matches the UI-SPEC Copywriting Contract verbatim.
|
|
||||||
- `grep -c 'LightningChannelModal' neode-ui/src/components/LightningChannelsPanel.vue` is at least 2.
|
|
||||||
- `grep -c "'mesh.lightning-peers'" neode-ui/src/api/rpc-client.ts` equals 1.
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero missing methods.
|
|
||||||
- `cd neode-ui && npx vitest run` exits 0 and `npm run build` exits 0.
|
|
||||||
- No new route, nav item, or dashboard card was added — confirmed by `git diff --stat` showing no change to the router or any layout/nav component, recorded in the SUMMARY.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>A trusted federated node can be picked by hostname and a channel opened with one click, through the house modal shell, on the demo and against a real node.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: The meshed Lightning peers list and the Request Channel flow</name>
|
|
||||||
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/mock-backend.js</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/src/components/federation/PeerRequestModal.vue` — the whole file (66 lines): the
|
|
||||||
optional message field, the `sending` → "Sending…" busy label, and the
|
|
||||||
`$emit('send', message)` / `$emit('cancel')` contract. Reuse this component rather than
|
|
||||||
building a second request modal.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-05 Visual Anchor"
|
|
||||||
section (trusted list is primary, meshed list second) and the empty-state copy rows.
|
|
||||||
- `neode-ui/src/views/federation/NodeList.vue` — the empty-state block treatment to mirror.
|
|
||||||
- The plan-01-07 SUMMARY — the exact params `mesh.request-channel` expects.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add the meshed-Lightning-peers section below the trusted list: fetch via `mesh.lightning-peers`,
|
|
||||||
render rows in the same layout with a Request Channel button in place of Open Channel, and sort
|
|
||||||
with the same stable ordering rule.
|
|
||||||
|
|
||||||
Deduplicate across the two lists: a peer that is both a trusted federated node and a meshed
|
|
||||||
Lightning peer appears only in the trusted list. Match on the identity available in both payloads
|
|
||||||
(the node's Lightning URI is the reliable common key; fall back to the peer's archipelago identity
|
|
||||||
key when present). Never match on display name.
|
|
||||||
|
|
||||||
Wire Request Channel to `PeerRequestModal` — mount it with the optional message field, and on its
|
|
||||||
send event call `mesh.request-channel` with the target peer and the message. While a request is
|
|
||||||
in flight the row's button is disabled and shows the busy label; a second click is a no-op. On
|
|
||||||
success show a sent-request confirmation on the row. That confirmation must not claim a channel
|
|
||||||
exists, is pending funding, or is connected; it says a request was sent and nothing more.
|
|
||||||
|
|
||||||
Add the shared empty state: when the trusted list and the meshed list are BOTH empty, render the
|
|
||||||
UI-SPEC's empty heading and body exactly once for the pair — not once per list. When only one is
|
|
||||||
empty, that section renders nothing rather than its own empty state. Render the house loading
|
|
||||||
treatment per section while its fetch is in flight, and the contract's error row on a failed
|
|
||||||
fetch, using the same `openError` / startup-notice idiom as Task 1.
|
|
||||||
|
|
||||||
Extend the mock backend so `mesh.lightning-peers` returns a small demo peer set and
|
|
||||||
`mesh.request-channel` records the request in the session store so the demo shows the same sent
|
|
||||||
state a real node does.
|
|
||||||
|
|
||||||
Extend the test suite: both-empty renders one empty state; one-empty renders none for that
|
|
||||||
section; a peer present in both lists renders once and in the trusted list; ordering is identical
|
|
||||||
across two consecutive renders of a shuffled input; a double click on Request Channel produces one
|
|
||||||
call; the sent confirmation contains no open or connected wording.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the six cases above present by name.
|
|
||||||
- The both-empty case asserts an element count of exactly 1 for the empty-state element, not merely that it is present.
|
|
||||||
- `grep -c 'PeerRequestModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2.
|
|
||||||
- `grep -c 'Request Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0.
|
|
||||||
- `cd neode-ui && npx vitest run && npm run build` exits 0.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Share your own URI, and the manual-paste fallback</name>
|
|
||||||
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts</files>
|
|
||||||
<read_first>
|
|
||||||
- `neode-ui/src/components/SendBitcoinModal.vue` — its `copyDetail` / "Copied!" clipboard
|
|
||||||
feedback pattern (the label flips for about two seconds). Reuse it; do not invent a new
|
|
||||||
copy-feedback idiom.
|
|
||||||
- `neode-ui/src/components/LightningChannelsPanel.vue` lines 250-270 and 595-625 — the
|
|
||||||
`pubkey@host:port` placeholder, the `Format: pubkey@host:port` helper text, and the
|
|
||||||
validate-before-RPC sequence including the address-optional split.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract rows
|
|
||||||
for "Primary CTA — own URI" and "Manual fallback entry point", and the UI Considerations rows
|
|
||||||
marked backstop for the manual-paste form.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Add the own-node URI block at the top of the modal: read this node's Lightning identity from
|
|
||||||
`lnd.getinfo`, display the URI truncated to its container with the full value in a `title`
|
|
||||||
tooltip, and add a copy button whose label flips to the confirmation string for about two seconds.
|
|
||||||
The clipboard receives the full untruncated value regardless of visual truncation. When the node
|
|
||||||
has no Lightning URI available, the block explains that instead of showing an empty field or a
|
|
||||||
fabricated address.
|
|
||||||
|
|
||||||
Add the manual-paste fallback below both lists as a de-emphasised disclosure, not a third
|
|
||||||
equal-weight column: the entry point reveals a peer URI input with the placeholder and helper text
|
|
||||||
reused verbatim from the Lightning panel. Validate client-side before calling the open RPC — a
|
|
||||||
value that is not in `pubkey@host:port` form is rejected with the format message and no RPC is
|
|
||||||
issued; a bare pubkey with no host is accepted and passes an undefined address through, which is
|
|
||||||
the behavior the open RPC already supports. Reuse the same error ref and startup-notice treatment.
|
|
||||||
|
|
||||||
Extend the test suite: the copy button places the full untruncated URI on the clipboard and its
|
|
||||||
label flips then reverts; the URI element carries a `title` with the full value; an invalid
|
|
||||||
pasted value shows the format message and issues no RPC call; a bare pubkey issues the open call
|
|
||||||
with an undefined address; the no-URI-available state renders its explanation rather than an
|
|
||||||
empty field.
|
|
||||||
|
|
||||||
Then verify on the dev preview against archi-dev before this plan is considered complete, per the
|
|
||||||
user requirement in CONTEXT.md: the preview at the dev port, the copy button, the trusted list,
|
|
||||||
the meshed list, the request flow, and the manual paste. Record what was exercised in the SUMMARY.
|
|
||||||
The blocking human sign-off is consolidated into plan 01-09.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && npm run build</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the five cases above present by name.
|
|
||||||
- A test asserts the clipboard receives the full untruncated URI even when the rendered element is truncated.
|
|
||||||
- A test asserts an invalid pasted value results in zero RPC calls (assert on the call count, not merely on the message being visible).
|
|
||||||
- `grep -c 'Copy Lightning URI' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
|
|
||||||
- `grep -c 'Paste URI Manually' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
|
|
||||||
- `grep -c 'pubkey@host:port' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (placeholder + helper text).
|
|
||||||
- `cd neode-ui && npx vitest run && npm run build` exits 0.
|
|
||||||
- The SUMMARY lists the dev-preview steps exercised against archi-dev and what was observed.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>A user can share their own node's Lightning URI and fall back to a pasted URI with real client-side validation, verified on the dev preview against a real node.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| daemon RPC → browser | Peer-advertised Lightning URIs, some of them from unauthenticated radio peers, are rendered and offered as payment targets |
|
|
||||||
| browser → clipboard | This node's payment endpoint is copied for the user to share out of band |
|
|
||||||
| user click → `lnd.openchannel` | A UI action commits real funds to a channel |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-31 | Spoofing | a meshed peer's advertised URI presented with the same authority as a bilaterally-trusted federated node, luring funds to an attacker | high | mitigate | The two lists stay visually and semantically distinct; the meshed action is Request Channel, never a direct open; the prohibition above states this and a test asserts the meshed row's action wording |
|
|
||||||
| T-01-32 | Tampering | a peer-supplied node name or URI containing markup that renders as UI | high | mitigate | Vue's default text interpolation escapes; the plan uses no `v-html` anywhere. A test asserting a name containing angle brackets renders as text is required before this row can be dispositioned |
|
|
||||||
| T-01-33 | Repudiation | a sent request being read as an open channel, so a user believes they have inbound liquidity they do not | high | mitigate | The sent confirmation is worded as a request only; a test asserts the confirmation contains no open or connected wording |
|
|
||||||
| T-01-34 | Denial of Service | a double click or a fast repeat committing two channel opens to the same peer | high | mitigate | An in-flight flag keyed to the target disables the action and makes a second click a no-op, backed by the plan-01-07 backend suppression window; a test asserts exactly one call for two immediate clicks |
|
|
||||||
| T-01-35 | Information Disclosure | this node's Lightning URI being displayed to a shoulder-surfer or copied in a shared session | low | accept | A Lightning URI is a public payment endpoint by design; it is deliberately shareable and carries no spend authority |
|
|
||||||
| T-01-36 | Elevation of Privilege | the modal bypassing the open RPC's server-side validation by calling with unvalidated input | medium | mitigate | Client-side validation is additive only; the existing server-side pubkey-format and amount-bounds validation in the open handler is reused unchanged and is the authority |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd neode-ui && npx vitest run` — green.
|
|
||||||
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
|
|
||||||
- `cd neode-ui && npm run build` — green.
|
|
||||||
- Dev-preview walkthrough against archi-dev recorded in the SUMMARY (own URI copy, trusted open, meshed request, manual paste), per CONTEXT.md's "verified on the dev preview before any deploy" requirement.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Own-node URI is displayed, truncated with a tooltip, and copied in full.
|
|
||||||
- Trusted federated nodes with Lightning are listed by hostname with a one-click open.
|
|
||||||
- Meshed Lightning peers are listed separately and requestable, deduplicated against the trusted list.
|
|
||||||
- Shared empty state renders once; loading and error states follow the house conventions.
|
|
||||||
- Manual paste validates client-side and supports a bare pubkey.
|
|
||||||
- Double-submission is impossible; a request is never shown as a channel.
|
|
||||||
- No new route, nav entry, or dashboard card was added.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 09
|
|
||||||
type: execute
|
|
||||||
wave: 5
|
|
||||||
depends_on: ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08"]
|
|
||||||
files_modified:
|
|
||||||
- .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
|
|
||||||
- core/archipelago/src/federation/storage.rs
|
|
||||||
- core/archipelago/src/federation/sync.rs
|
|
||||||
- core/archipelago/src/api/rpc/federation/handlers.rs
|
|
||||||
- core/archipelago/src/fips/dial.rs
|
|
||||||
- core/archipelago/src/mesh/mod.rs
|
|
||||||
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
|
|
||||||
autonomous: true
|
|
||||||
requirements: [FED-03, FED-01]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A findings document exists listing every issue the structured review of the federation/fleet area and the mesh area produced, with file and line citations"
|
|
||||||
- "Every finding carries exactly one disposition — fixed, or deferred with a written reason — and no finding is left without one (FED-03 ordering edge)"
|
|
||||||
- "Every reviewed area appears in the document, including areas where the review produced no findings, recorded as reviewed with none rather than omitted (FED-03 empty edge)"
|
|
||||||
- "Every federation and mesh claim in the codebase concerns document is re-verified against current code and git history before being filed as a finding or dismissed, with the evidence cited (FED-03 adjacency edge)"
|
|
||||||
- "Every finding marked fixed cites the commit and the test or command that demonstrates the fix"
|
|
||||||
- "The known-fixed claims are recorded as already-fixed with their commit, not re-fixed"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "A finding MUST NOT be closed as fixed without evidence a reader can re-run — a disposition of fixed always cites a commit and a verifying command or test name, never an assertion alone"
|
|
||||||
category: transparency
|
|
||||||
- statement: "A finding MUST NOT be dropped silently — an item judged out of scope is recorded as deferred with the reason and the phase or requirement that owns it, never deleted from the list"
|
|
||||||
category: transparency
|
|
||||||
artifacts:
|
|
||||||
- path: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
|
|
||||||
provides: "The FED-03 structured review output with per-finding dispositions"
|
|
||||||
min_lines: 60
|
|
||||||
key_links:
|
|
||||||
- from: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
|
|
||||||
to: .planning/codebase/CONCERNS.md
|
|
||||||
via: "each federation/mesh concern is re-verified and cross-referenced by its finding id"
|
|
||||||
pattern: "CONCERNS"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Run the structured code review FED-03 requires over the federation/fleet area and the mesh area, and
|
|
||||||
close it out: every finding fixed or explicitly deferred with a reason.
|
|
||||||
|
|
||||||
Purpose: FED-03. RESEARCH.md Pitfall 2 is the governing constraint — `.planning/codebase/CONCERNS.md`
|
|
||||||
is NOT current truth for this phase. At least two of its federation claims were already fixed on main
|
|
||||||
before this phase started (the tombstone-write-swallowed claim was fixed in `01cbec27`; the
|
|
||||||
peer-joined DID path does verify an ed25519 signature). Re-fixing an already-fixed bug wastes the
|
|
||||||
review and risks reverting working code, so every claim gets a fresh code read plus a git-history
|
|
||||||
check before it is filed or dismissed.
|
|
||||||
Output: `01-REVIEW-FINDINGS.md` with a disposition on every finding, the small findings fixed inline,
|
|
||||||
and the phase's code deployed to the dev pair so plan 01-10's verification has something to test.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/codebase/CONCERNS.md
|
|
||||||
@.planning/codebase/ARCHITECTURE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
Created or changed by **this plan**:
|
|
||||||
|
|
||||||
| Symbol | Kind | File |
|
|
||||||
|---|---|---|
|
|
||||||
| `01-REVIEW-FINDINGS.md` | new findings document with per-finding dispositions | `.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md` |
|
|
||||||
| finding-dependent fixes | code changes in the reviewed areas | files listed in `files_modified` |
|
|
||||||
| dev-pair deployment | the phase build running on archi-dev-box and x250-dev, sha256-verified | (no repo file) |
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="tracer">
|
|
||||||
<name>Task 1: End-to-end — one finding from discovery to closed disposition</name>
|
|
||||||
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
|
|
||||||
<read_first>
|
|
||||||
- `.planning/codebase/CONCERNS.md` — the federation and mesh entries: the node-removal tombstone
|
|
||||||
gap (cited at `federation/storage.rs:180-197`), the incomplete federation DID validation, the
|
|
||||||
unbounded harness curl (cited as a multinode test-harness issue), the node-list dedup scaling
|
|
||||||
note, and the mesh radio configuration boot race.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — the "Common Pitfalls" section
|
|
||||||
(especially Pitfall 2's instruction to `git log -p` each cited range before acting) and the
|
|
||||||
"Assumptions Log" rows A1, A2, and A5. A5 in particular is explicitly NOT independently
|
|
||||||
re-verified and must be re-checked here.
|
|
||||||
- The SUMMARYs from plans 01-01, 01-05, and 01-07 — what has already been fixed in this phase,
|
|
||||||
so those items are recorded as fixed-by-this-phase rather than re-opened.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Create `01-REVIEW-FINDINGS.md` with a table whose columns are: finding id (`F-01`, `F-02`, …),
|
|
||||||
area (federation store / federation sync / federation RPC / FIPS-transport dial / mesh core /
|
|
||||||
mesh RPC surface), severity, the file and line citation, the evidence (what was read and what
|
|
||||||
`git log -p` or `git blame` showed), the disposition (`fixed` / `already-fixed` / `deferred`), and
|
|
||||||
for `fixed` the commit plus the verifying command or test name, or for `deferred` the reason and
|
|
||||||
the owning phase or requirement.
|
|
||||||
|
|
||||||
Then take exactly one finding all the way through in this task, to prove the pipeline: re-verify
|
|
||||||
the codebase-concerns claim about incomplete federation DID validation — specifically the part
|
|
||||||
RESEARCH.md flags as un-re-verified, whether anything checks proof of ownership of a DID on first
|
|
||||||
contact, as opposed to the peer-joined path which does verify a signature. Read the add-node and
|
|
||||||
peer-joined paths in the federation RPC handlers and run `git log -p` on them. File the finding
|
|
||||||
with its evidence, then either fix it (if the fix is contained and does not touch federation trust
|
|
||||||
or join cryptography beyond what correctness requires — CONTEXT.md's scope fence) or defer it with
|
|
||||||
a written reason naming what a fix would touch and why that belongs elsewhere.
|
|
||||||
|
|
||||||
Record the two claims RESEARCH.md already verified as fixed with their commits, as `already-fixed`
|
|
||||||
rows, so a future reader does not re-open them.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>test -f .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md && grep -Eq '^\| *F-01' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md && cd core && cargo test -p archipelago federation</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `01-REVIEW-FINDINGS.md` exists with a header row and at least one `F-NN` row.
|
|
||||||
- The first finding's row has a non-empty evidence cell naming the command that produced it and a non-empty disposition cell.
|
|
||||||
- Rows exist recording both already-fixed claims with their commit hashes.
|
|
||||||
- `cd core && cargo test -p archipelago federation` exits 0 (if the first finding was fixed here, its test is included).
|
|
||||||
- The SUMMARY quotes the `git log -p` output excerpt that decided the first finding.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The findings document exists and one finding has travelled the full path from claim to evidence to disposition.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Complete the review across both areas and disposition every finding</name>
|
|
||||||
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md, core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/fips/dial.rs, core/archipelago/src/mesh/mod.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs</files>
|
|
||||||
<read_first>
|
|
||||||
- `core/archipelago/src/federation/` — `storage.rs`, `sync.rs`, `types.rs`, `invites.rs`, `mod.rs`
|
|
||||||
as left by plans 01-01, 01-05, and 01-06.
|
|
||||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` — the full RPC surface, including the
|
|
||||||
peer-joined, peer-did-changed, and peer-address-changed signature-verification paths.
|
|
||||||
- `core/archipelago/src/fips/dial.rs` and the transport dial/fallback path — the FIPS-to-Tor
|
|
||||||
fast-fail behaviour FED-03 names as in scope.
|
|
||||||
- `core/archipelago/src/mesh/mod.rs` — `purge_federation_peer`, `upsert_federation_peer`,
|
|
||||||
`seed_federation_peers_into_mesh`; and `core/archipelago/src/api/rpc/mesh/typed_messages.rs`
|
|
||||||
as left by plans 01-04 and 01-07.
|
|
||||||
- `.planning/codebase/CONCERNS.md` — every remaining federation and mesh entry.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` — the mock-parity residual
|
|
||||||
class that plan flagged as a candidate finding for this review.
|
|
||||||
- `.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md` — the scope fence: do not touch
|
|
||||||
federation trust or join cryptography beyond what removal and sync correctness require, and no
|
|
||||||
data-destroying migrations.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Review each area in turn and add its findings to the document. For each codebase-concerns claim,
|
|
||||||
do the fresh read plus `git log -p` on the cited range BEFORE filing or dismissing it, and put
|
|
||||||
that evidence in the row — a finding that merely restates a concerns bullet without fresh
|
|
||||||
evidence is not admissible.
|
|
||||||
|
|
||||||
Areas to cover, each of which must appear in the document even when it produced no findings —
|
|
||||||
record those as reviewed with none rather than omitting them: federation store, federation sync,
|
|
||||||
federation RPC surface, FIPS and transport dial, mesh core, mesh RPC surface.
|
|
||||||
|
|
||||||
Required specific checks, each of which becomes a row:
|
|
||||||
- The mock-parity residual class flagged in the plan 01-02 SUMMARY (a mock case that exists but
|
|
||||||
returns a differently-shaped success object than the daemon).
|
|
||||||
- Whether the paid-tick grep from plan 01-03 still finds exactly the two surfaces it found at
|
|
||||||
planning time, or whether a third has appeared.
|
|
||||||
- The unbounded-curl concern: confirm it belongs to the multinode test harness and defer it to
|
|
||||||
the phase that owns that requirement, with that phase named in the reason.
|
|
||||||
- The node-list dedup scaling note: disposition it with the peer counts this fleet actually runs.
|
|
||||||
- The mesh radio configuration boot race: confirm against current code and defer if it needs real
|
|
||||||
LoRa hardware, naming that as the reason.
|
|
||||||
|
|
||||||
Fix findings that are contained — a bounded change inside the reviewed area with a test — and
|
|
||||||
commit each as its own focused commit per CLAUDE.md. Defer anything that would breach the
|
|
||||||
CONTEXT.md scope fence, require hardware this session lacks, or belong to another phase, and write
|
|
||||||
the reason and the owner in the row. Every row ends with exactly one disposition.
|
|
||||||
|
|
||||||
Finish with a short summary section stating the counts: findings filed, fixed, already-fixed, and
|
|
||||||
deferred; and a line stating that the counts sum to the number of rows.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd core && cargo test -p archipelago && cd ../neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo test -p archipelago` exits 0.
|
|
||||||
- `cd neode-ui && npx vitest run` exits 0 and `node scripts/mock-rpc-parity.mjs` exits 0.
|
|
||||||
- Every row in `01-REVIEW-FINDINGS.md` has a non-empty disposition cell — verify by counting rows and counting non-empty disposition cells and asserting the two numbers match; record both numbers in the SUMMARY.
|
|
||||||
- All six named areas appear in the document.
|
|
||||||
- The summary section's counts sum to the row count.
|
|
||||||
- Every `fixed` row cites a commit hash and a verifying command or test name.
|
|
||||||
- Every `deferred` row has a non-empty reason and names an owning phase or requirement.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Both areas are reviewed, every finding has exactly one evidenced disposition, and the contained fixes are committed.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Build and deploy the phase to the dev pair, sha256-verified</name>
|
|
||||||
<precondition>archi-dev-box and x250-dev are reachable over the fleet network — confirm with a bounded connectivity probe to each before starting; if either is unreachable, halt rather than deploying to a partial pair.</precondition>
|
|
||||||
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
|
|
||||||
<read_first>
|
|
||||||
- `scripts/deploy-to-target.sh` — the established deploy path and the environment variable it
|
|
||||||
takes for the target. Read it fully before running it; do not hand-roll a deploy.
|
|
||||||
- `CLAUDE.md` — the build instructions (cargo from `core/`; frontend build outputs to
|
|
||||||
`web/dist/neode-ui/`; grep the built bundle for new strings because the build can silently
|
|
||||||
no-op) and the deploy discipline (dev pair before any OTA).
|
|
||||||
- The project memory note on deploying via service restart while containers are running — confirm
|
|
||||||
what the deploy script does about restarts before running it, and record the answer.
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Build the backend from `core/` and the frontend from `neode-ui/`, then grep the built frontend
|
|
||||||
bundle for a string introduced by this phase to prove the build is not stale.
|
|
||||||
|
|
||||||
Deploy to archi-dev-box and then to x250-dev using the established deploy script, one at a time.
|
|
||||||
After each, verify the deployed binary's sha256 matches the locally built artifact, and record
|
|
||||||
both hashes. After each deploy, check that the node's app containers are still running and record
|
|
||||||
the result — a deploy that takes containers down is a finding, not a success.
|
|
||||||
|
|
||||||
Add a short deployment section to `01-REVIEW-FINDINGS.md` recording: the built artifact hashes,
|
|
||||||
the two target hostnames, the per-target sha256 match, the container-survival result, and the
|
|
||||||
frontend bundle grep result.
|
|
||||||
|
|
||||||
Do not deploy to any other fleet node, do not cut a release, and do not publish an OTA — this
|
|
||||||
phase ends at the dev pair plus the verification in plan 01-10.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>grep -Eq 'sha256' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `cd core && cargo build --release -p archipelago` exits 0 and `cd neode-ui && npm run build` exits 0.
|
|
||||||
- The built frontend bundle contains a string introduced by this phase — assert with a grep over `web/dist/neode-ui/assets/` for the badge ring class name added in plan 01-03.
|
|
||||||
- The deployment section records two target hostnames, two sha256 pairs that match, and a container-survival result per target.
|
|
||||||
- No release tag was created and no OTA manifest was published — confirmed by `git tag --points-at HEAD` producing no output, recorded in the SUMMARY.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>The phase's code is running on both dev-pair nodes, provably the artifact that was built, with containers intact.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| developer workstation → fleet node (deploy) | A built binary crosses onto a live node over SSH |
|
|
||||||
| review process → codebase | A fix applied during review changes federation trust-adjacent code |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-37 | Tampering | a deployed binary differing from the one built and tested | high | mitigate | Per-target sha256 comparison against the local artifact, both hashes recorded (Task 3) |
|
|
||||||
| T-01-38 | Denial of Service | a deploy restarting the service and killing running app containers | high | mitigate | The established deploy script is read before use and container survival is checked and recorded per target; a container loss is filed as a finding |
|
|
||||||
| T-01-39 | Elevation of Privilege | a review fix loosening federation trust or join verification | high | mitigate | CONTEXT.md's scope fence is a required read; findings needing trust-code changes are deferred with the reason rather than patched here; the full test suite gates each fix |
|
|
||||||
| T-01-40 | Repudiation | a finding quietly dropped so a known issue leaves no trace | medium | mitigate | The row-count-equals-disposition-count check and the summing counts section make an omission detectable; the prohibitions above state the rule |
|
|
||||||
| T-01-41 | Information Disclosure | deploy credentials or node passwords committed while recording deployment evidence | high | mitigate | The deployment section records hostnames and hashes only; per CLAUDE.md, never commit secrets. Stage by explicit path and review the diff before committing |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `cd core && cargo test -p archipelago` — green.
|
|
||||||
- `cd neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs && npm run build` — green.
|
|
||||||
- `01-REVIEW-FINDINGS.md` row count equals its disposition count, and the summary counts sum to it.
|
|
||||||
- Both dev-pair nodes report a matching sha256 and surviving containers.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- A findings document covers six named areas, including those with no findings.
|
|
||||||
- Every finding has exactly one evidenced disposition; fixed rows cite commit and test, deferred rows cite reason and owner.
|
|
||||||
- Every codebase-concerns federation/mesh claim was re-verified against current code and git history before being filed or dismissed.
|
|
||||||
- The phase is deployed to the dev pair, sha256-verified, with containers intact and no release cut.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md` when done.
|
|
||||||
Stage by explicit path, commit each fix separately, and `git push gitea-ai main`.
|
|
||||||
</output>
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-federation-mesh-hardening
|
|
||||||
plan: 10
|
|
||||||
type: execute
|
|
||||||
wave: 6
|
|
||||||
depends_on: ["01-09"]
|
|
||||||
files_modified: []
|
|
||||||
autonomous: false
|
|
||||||
requirements: [FED-01, FED-02, FED-05, FED-06]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "An operator who removes a federated peer on a live node does not see it reappear after at least two subsequent sync cycles"
|
|
||||||
- "A peer whose sync is failing shows the operator-visible sync-error badge on the live node, and the badge clears once that peer syncs successfully"
|
|
||||||
- "The channel-open flow works against a real node on the dev preview: the own-node URI copies, a trusted federated node opens in one click, a meshed Lightning peer can be sent a request, and a manually pasted URI is accepted"
|
|
||||||
- "The paid tick renders the branded ring on both payment-success surfaces on the dev preview, at a narrow and a desktop viewport, without clipping"
|
|
||||||
- "The demo and a real node behave the same through the mesh chat surface — aliasing a peer, reacting, editing, deleting, and sending an attachment produce the same modals and the same outcome on both"
|
|
||||||
prohibitions:
|
|
||||||
- statement: "The phase MUST NOT be signed off on demo evidence alone — every criterion in this checkpoint that names a real node is exercised against a real node, because a demo-only pass is exactly the divergence class this phase exists to remove"
|
|
||||||
category: transparency
|
|
||||||
artifacts: []
|
|
||||||
key_links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Consolidate every human-gated verification this phase owes into one sign-off, run against the dev
|
|
||||||
pair rather than the demo.
|
|
||||||
|
|
||||||
Purpose: `01-VALIDATION.md` lists three manual-only verifications (removal sticking across real sync
|
|
||||||
cycles, the channel-open flow end to end, and the paid-tick visual), and CONTEXT.md adds the user's
|
|
||||||
own requirement that FED-05 and FED-06 are verified on the dev preview against archi-dev **before any
|
|
||||||
deploy**. Rather than interrupting each implementation plan with its own checkpoint, they are gathered
|
|
||||||
here so the operator is asked once, after the code is on the dev pair.
|
|
||||||
Output: a recorded sign-off, or a list of issues that becomes the input to a gap-closure pass.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-VALIDATION.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Artifacts this phase produces
|
|
||||||
|
|
||||||
This plan produces no new symbols. It verifies the artifacts produced by plans 01-01 through 01-09:
|
|
||||||
the serialized federation store, the sync-error badge, the mesh Lightning identity and request
|
|
||||||
messages, the federation Lightning URI field, the channel-open picker modal, the branded paid tick,
|
|
||||||
and the demo RPC parity harness.
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="checkpoint:human-verify" gate="blocking">
|
|
||||||
<name>Task 1: Phase 1 consolidated verification on the dev pair</name>
|
|
||||||
<what-built>
|
|
||||||
Phase 1 in full, deployed to archi-dev-box and x250-dev and sha256-verified by plan 01-09:
|
|
||||||
- Federation node-store writes are serialized behind one lock with an atomic node-list write, so
|
|
||||||
a removal issued during a sync pass can no longer be undone by that sync.
|
|
||||||
- One periodic federation sync loop instead of two; per-peer sync failures are persisted and
|
|
||||||
shown as a badge on the node row, clearing when the peer recovers; out-of-order snapshots can
|
|
||||||
no longer move a peer's state backwards.
|
|
||||||
- A new channel-open picker modal reached from the Lightning panel's existing Open Channel
|
|
||||||
button: your own node's Lightning URI with a copy button, trusted federated nodes listed by
|
|
||||||
hostname with a one-click open, meshed peers running Lightning listed separately with a
|
|
||||||
request flow, and a manual URI paste fallback.
|
|
||||||
- The payment-success tick now uses the screensaver ring with its EQ segments on both the send
|
|
||||||
modal and the scan modal.
|
|
||||||
- The demo backend answers every mesh and federation RPC the UI calls, and reactions, edits,
|
|
||||||
deletes, and peer aliasing change demo state instead of returning a bare acknowledgement.
|
|
||||||
</what-built>
|
|
||||||
<how-to-verify>
|
|
||||||
Run these against the dev pair, not the demo, except where a step says demo.
|
|
||||||
|
|
||||||
1. **Removal sticks (FED-01).** On archi-dev-box, open the Federation view and remove a federated
|
|
||||||
peer. Wait through at least two auto-sync cycles — the loop runs every 90 seconds, so give it
|
|
||||||
four minutes — then reload. Expected: the peer is gone and stays gone. Then try removing a peer
|
|
||||||
that no longer exists (repeat the removal): expected an error message, not a silent success.
|
|
||||||
|
|
||||||
2. **Sync errors are visible (FED-02).** Make one federated peer unreachable — take its node off
|
|
||||||
the network, or block it — and wait one sync cycle. Expected: that node's row shows a sync-error
|
|
||||||
badge, and hovering it shows the error text and when it happened. Bring the peer back and wait
|
|
||||||
one more cycle. Expected: the badge clears on its own.
|
|
||||||
|
|
||||||
3. **Channel opening (FED-05).** Open the dev preview pointed at archi-dev and go to the Lightning
|
|
||||||
channels panel, then click Open Channel. Expected: a full-screen modal (the backdrop covers the
|
|
||||||
whole window and clicking outside closes it), showing your node's Lightning URI at the top.
|
|
||||||
Click Copy Lightning URI: expected the label flips to Copied! for about two seconds, and pasting
|
|
||||||
elsewhere gives the complete URI even though the on-screen text is shortened. Check the trusted
|
|
||||||
list shows your federated nodes by hostname with their FIPS or Tor badge. Check the meshed
|
|
||||||
Lightning peers list below it. Click Request Channel on a meshed peer, add a short message, and
|
|
||||||
send: expected a "request sent" style confirmation that does NOT claim a channel is open or
|
|
||||||
connected. Click Paste URI Manually, enter something malformed such as text with no at-sign:
|
|
||||||
expected a format message and no attempt to open. Then paste a valid peer URI: expected the
|
|
||||||
normal open flow. Finally, double-click Open Channel on a trusted node: expected one open
|
|
||||||
attempt, with the button disabled while it runs.
|
|
||||||
|
|
||||||
4. **Paid tick (FED-06).** On the dev preview, trigger a payment success in the send modal and in
|
|
||||||
the scan modal. Expected: the checkmark now sits inside the screensaver-style ring with the
|
|
||||||
radiating segment lines, at both a narrow phone width and a desktop width, with nothing cut off
|
|
||||||
by the edge of the card. The amount and the SENT wording are unchanged.
|
|
||||||
|
|
||||||
5. **Demo and real node match (FED-04).** On the demo, rename a mesh peer, react to a message,
|
|
||||||
edit one, delete one, and send a small file attachment. Then do the same on archi-dev.
|
|
||||||
Expected: the same modals appear in the same situations, the changes are visible in both, and
|
|
||||||
the browser console shows no "Method not found" errors on either.
|
|
||||||
|
|
||||||
6. **Single-node gate stays green (CLAUDE.md mandate).** Phase 1 modified daemon internals
|
|
||||||
(`federation/storage.rs`, `server.rs` — a periodic loop was removed), which falls under the
|
|
||||||
"re-run the gate after orchestrator/lifecycle changes" rule. Run `tests/lifecycle/run-gate.sh`
|
|
||||||
ON a dev-pair node (gate runs on-node, never via RPC). Expected: green, 0 not-ok. A full 5×
|
|
||||||
run on .228 is NOT required here (that is Phase 3's multinode criterion) — one clean pass on
|
|
||||||
the dev pair is the insurance this checkpoint needs.
|
|
||||||
|
|
||||||
If anything fails, describe what you saw and which numbered step it was — that becomes the gap
|
|
||||||
list for a closure pass rather than a re-run of the whole phase.
|
|
||||||
</how-to-verify>
|
|
||||||
<resume-signal>Type "approved" to sign off Phase 1, or describe the issues by step number.</resume-signal>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| operator judgement → phase sign-off | A human verdict gates whether this phase is considered complete |
|
|
||||||
| live fleet node → operator observation | Verification runs against real nodes carrying real federation trust and real Lightning funds |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-01-42 | Repudiation | signing off on demo evidence while a real node still fails | high | mitigate | Each step names where it runs; the prohibition above forbids demo-only sign-off; step 5 explicitly compares the two |
|
|
||||||
| T-01-43 | Elevation of Privilege | a removed peer regaining federation membership unnoticed because the check was too short | high | mitigate | Step 1 requires waiting at least two sync cycles at the 90-second interval, stated as a wall-clock duration rather than "a while" |
|
|
||||||
| T-01-44 | Denial of Service | the verification itself taking a live node off the network and leaving it that way | medium | mitigate | Step 2 restores the peer as part of the step and requires observing the badge clear, so the node cannot be left isolated as a side effect |
|
|
||||||
| T-01-45 | Spoofing | a channel opened against a peer-advertised URI during verification sending funds to the wrong node | high | mitigate | Step 3's request path targets a meshed peer with a request, not an open; the one-click open is exercised only against a bilaterally-trusted federated node the operator already federated with |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
The operator's response is the verification. An "approved" response completes the phase; any
|
|
||||||
described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- All five numbered checks were exercised, each in the place it names.
|
|
||||||
- The operator either approved or produced a numbered issue list.
|
|
||||||
- The outcome is recorded in the SUMMARY, including which node each check ran against.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/phases/01-federation-mesh-hardening/01-10-SUMMARY.md` when done, recording the
|
|
||||||
verdict, the node each check ran against, and any issue text verbatim.
|
|
||||||
</output>
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# Phase 1 Context — Federation & Mesh Hardening
|
|
||||||
|
|
||||||
**Source:** User decisions captured in conversation 2026-07-29 (no full discuss-phase run)
|
|
||||||
|
|
||||||
<domain>
|
|
||||||
Federation/fleet + mesh hardening on a live OTA fleet, plus two lightning-adjacent UI features
|
|
||||||
(channel-open UX, on-brand paid-tick animation). Backend: Rust workspace at core/. Frontend:
|
|
||||||
neode-ui (Vue), dev preview :8100 against archi-dev.
|
|
||||||
</domain>
|
|
||||||
|
|
||||||
<decisions>
|
|
||||||
- **FED-05 "public nodes" scope (LOCKED, user 2026-07-29, corrected same day):** the
|
|
||||||
public/other list in the channel-open picker = MESHED PEER NODES THAT HAVE LIGHTNING
|
|
||||||
INSTALLED — nodes known over the mesh (mesh peers/contacts beyond bilateral federation
|
|
||||||
trust) that advertise lightning capability. NOT lnd listpeers, NOT a curated list, NOT
|
|
||||||
a live LN-graph query. Implies peers need to advertise a "lightning installed/available"
|
|
||||||
capability (plus their URI/pubkey) over mesh/federation state so the picker can list
|
|
||||||
them. Manual URI paste can remain as a fallback entry path. Primary lists: (1) trusted
|
|
||||||
federated nodes by hostname, (2) meshed peers with lightning installed — "request to
|
|
||||||
open a channel with" these.
|
|
||||||
- **FED-05 URI sharing default (Claude's discretion, revisable):** a federated peer's
|
|
||||||
Lightning URI/pubkey rides the federation sync payload by default — federation trust is
|
|
||||||
already bilateral and explicit. Follow the existing shared-field pattern in
|
|
||||||
NodeStateSnapshot; if an opt-in toggle already exists for similar fields (e.g.
|
|
||||||
shared_location), mirror that pattern with default ON for lightning URI.
|
|
||||||
- **FED-06 (LOCKED, user):** paid-tick circle = ScreensaverRing.vue style (EQ segments),
|
|
||||||
applied consistently to every paid/success tick surface (SendBitcoinModal success pane,
|
|
||||||
WalletScanModal success-ring).
|
|
||||||
- **FED-04:** demo attachment parity core already shipped on main (c2ce71c6) — remaining
|
|
||||||
scope is the leftover mock gaps found in research (contacts-list/save, reaction/reply/
|
|
||||||
edit/delete/forward stubs that never mutate demo state).
|
|
||||||
- **Priority framing (user):** federation removal/sync correctness is the reason this phase
|
|
||||||
exists — "we should just be working on making that as tight as possible".
|
|
||||||
</decisions>
|
|
||||||
|
|
||||||
<specifics>
|
|
||||||
- UI work verified on the :8100 dev preview against archi-dev BEFORE any deploy (user
|
|
||||||
requirement, applies to FED-05/FED-06).
|
|
||||||
- Deploy discipline per CLAUDE.md: dev pair before OTA; commit+push every unit of work.
|
|
||||||
- Modals must Teleport to body (repeated user complaint — see project feedback memory).
|
|
||||||
</specifics>
|
|
||||||
|
|
||||||
<deferred>
|
|
||||||
- Live Lightning-graph search of arbitrary public nodes (not connected peers) — out of
|
|
||||||
scope for FED-05 v1.
|
|
||||||
- Curated/shipped public-node directory — not wanted.
|
|
||||||
</deferred>
|
|
||||||
|
|
||||||
<scope_fence>
|
|
||||||
Do not touch federation trust/join cryptography beyond what removal/sync correctness
|
|
||||||
requires (STATE.md blocker: tombstone fix touches trust code — re-verify with
|
|
||||||
tests/multinode/smoke.sh, don't patch blind). No data-destroying migrations.
|
|
||||||
</scope_fence>
|
|
||||||
@ -1,371 +0,0 @@
|
|||||||
# Phase 1: Federation & Mesh Hardening - Research
|
|
||||||
|
|
||||||
**Researched:** 2026-07-29
|
|
||||||
**Domain:** Federation node sync/removal (Rust/Tokio async daemon), mesh RPC parity (Rust + Node.js mock backend), Lightning channel-open UX (Vue 3), on-brand success animation (Vue 3/CSS)
|
|
||||||
**Confidence:** HIGH (backend federation/mesh code — read directly, git-blamed); MEDIUM (FED-05 Lightning-URI UX — net-new surface, no prior art in repo); HIGH (FED-06 — both source components read directly)
|
|
||||||
|
|
||||||
<user_constraints>
|
|
||||||
## User Constraints (from CONTEXT.md)
|
|
||||||
|
|
||||||
No CONTEXT.md exists for this phase (not yet run through `/gsd-discuss-phase`). No locked decisions or discretion areas to honor beyond `REQUIREMENTS.md` and the phase description supplied by the orchestrator. Treat all implementation choices below as recommendations for the planner, not locked decisions — the planner should flag any of these that warrant a user check-in (see `## Assumptions Log`).
|
|
||||||
</user_constraints>
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The federation and mesh code is more mature than `CONCERNS.md` suggests — several concerns it lists (tombstone-write-swallowed, DID-join without signature verification) were already fixed in commit `01cbec27` (2026-07-02) and the `handle_federation_peer_joined` signature-verification path respectively. **Do not treat `CONCERNS.md` as current truth for this phase; the structured review (FED-03) must re-verify each claim against the code read in this research before acting on it.**
|
|
||||||
|
|
||||||
The real, currently-live bug class behind the user's "nodes reappear / sync issues" reports is almost certainly a **concurrency race on `federation/nodes.json`**: `federation/storage.rs` has zero locking (no `Mutex`, no atomic temp-file+rename) around `load_nodes()` → mutate → `save_nodes()`, yet the daemon runs **two independent, overlapping periodic federation-sync loops** (`server.rs` ~line 497, every 90s; `server.rs` ~line 840, every 1800s) plus the manual `federation.sync-state` RPC and `federation.remove-node` RPC — all of which do their own read-modify-write cycle against the same file with no coordination. A `remove_node()` call racing against an in-flight `sync_with_peer()`'s `update_node_state()` (which read the node list *before* the removal landed) will have the sync's stale read clobber the just-written removal when it saves — the removed node reappears with no error, exactly matching the reported symptom, and it is invisible to logs because both loops only `debug!()` on failure. This is the primary hypothesis to design a fix and a regression test around for FED-01/FED-02.
|
|
||||||
|
|
||||||
Mesh attachment-send parity (FED-04) was fixed just before this phase started (commit `c2ce71c6`, uncommitted → committed by another concurrent agent during this research session): `mock-backend.js` now implements `mesh.send-content-inline` / `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` mirroring the daemon's real tier logic. What remains for full "rest of the mesh chat surface" parity: `mesh.contacts-list` / `mesh.contacts-save` (peer aliasing, called live from `Mesh.vue` on mount and on rename) are **not implemented in mock-backend.js at all** and will 404 with "Method not found" on the demo; and `mesh.send-reaction` / `send-reply` / `edit-message` / `delete-message` / `forward-message` are stubbed as bare `{ok:true}` acks that never mutate `meshStore.dynamic`, so reactions/edits/deletes silently don't render on the demo even though the RPC call "succeeds."
|
|
||||||
|
|
||||||
FED-05 (Lightning channel-open UX) is greenfield: no RPC anywhere in the codebase currently returns this node's own Lightning `identity_pubkey`/`uris` (LND's own `/v1/getinfo` provides both, but `handle_lnd_getinfo` in `core/archipelago/src/api/rpc/lnd/info.rs` doesn't parse or forward them), and `NodeStateSnapshot` (the federation sync payload) carries no Lightning fields for a peer's pubkey/host, so there is no way today to look up a *federated* peer's channel-open target. `handle_lnd_openchannel` (channels.rs) already accepts `pubkey` + optional `address` + `amount`/fee params and does the connect-then-open sequence correctly — reuse it as-is. "Public nodes" browse/request has no existing data source in this codebase (no LN graph query, no curated list) and needs a scope decision from the user before planning task breakdown.
|
|
||||||
|
|
||||||
FED-06 is a straightforward swap: `ScreensaverRing.vue` (`compact` size = 240px/320px) renders only the radiating EQ segments (no circle of its own — the "circle" is the separately-layered content in the center, exactly as `Screensaver.vue` does with `ScreensaverLogo`). `SendBitcoinModal.vue`'s `.send-success-burst` is 112px (7rem) with 3 CSS-ripple `.burst-ring` elements plus a `.burst-core` circle+checkmark — swap the `.burst-ring` elements for `<ScreensaverRing size="compact" />`, keep `.burst-core`+checkmark centered on top, and reconcile the size mismatch (ring is 2-3x larger than the current burst container; either scale it down via CSS `transform: scale()` or accept the larger footprint since the modal is `max-w-2xl`). `WalletScanModal.vue` has a second, simpler "paid tick" (`.success-ring`, no ripple animation at all) that the phase's "wherever else the paid tick appears" clause covers — plan to update both.
|
|
||||||
|
|
||||||
**Primary recommendation:** Start FED-03's structured review by (1) auditing every `federation::storage` read-modify-write call site for the missing-lock race described above and design a fix (a `tokio::sync::Mutex` per data_dir, or collapsing the two periodic sync loops into one), (2) re-verifying every `CONCERNS.md` federation/mesh claim against current code before acting on it, (3) filling the two demo-parity gaps in `mock-backend.js` (contacts-list/save + stateful reaction/edit/delete), (4) treating FED-05 as new RPC surface (own-node Lightning URI, peer Lightning info propagation, and a scoped "public nodes" answer) before any UI work, and (5) the FED-06 CSS/component swap.
|
|
||||||
|
|
||||||
## Architectural Responsibility Map
|
|
||||||
|
|
||||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
|
||||||
|------------|-------------|----------------|-----------|
|
|
||||||
| Federation node list, tombstones, sync loops | API / Backend (`core/archipelago/src/federation/`) | — | Disk-persisted state; must be race-free at the storage layer, not patched in the RPC or UI layer |
|
|
||||||
| Federation removal propagation to mesh chat | API / Backend (`core/archipelago/src/mesh/mod.rs::purge_federation_peer`) | Frontend (Pinia `stores/mesh.ts`) | Backend already purges peer/messages/contacts server-side; frontend must not cache a stale contact after the WebSocket state bump |
|
|
||||||
| Mesh RPC surface (attachments, reactions, contacts) | API / Backend (`core/archipelago/src/api/rpc/mesh/`) | Dev tooling (`neode-ui/mock-backend.js`) | The demo backend is a parity shim over the same RPC surface — it must mirror backend behavior, never invent its own contract |
|
|
||||||
| FIPS/Tor transport dial + fallback | API / Backend (`core/archipelago/src/fips/dial.rs`, `transport/`) | — | Transport selection is a backend concern; UI only displays the resulting badge (`last_transport`) |
|
|
||||||
| Lightning node URI (own + peer) | API / Backend (new: `lnd.getinfo` extension, federation sync payload extension) | Frontend (new modal) | LND is the source of truth for `identity_pubkey`/`uris`; federation sync is the transport for sharing a peer's LN info |
|
|
||||||
| Channel-open UX (initiate) | Frontend (new modal, `Teleport`-to-body, house style) | API / Backend (`lnd.openchannel` — already exists) | Backend channel-open RPC is complete; only the UI (URI share, trusted-peer picker, public-node browse) is missing |
|
|
||||||
| Paid-tick success animation | Frontend (`SendBitcoinModal.vue`, `WalletScanModal.vue`, `ScreensaverRing.vue`) | — | Pure presentation; no backend involvement |
|
|
||||||
|
|
||||||
## Standard Stack
|
|
||||||
|
|
||||||
This phase does not introduce new external dependencies. It is a hardening + UI-surface pass over an existing Rust (Tokio/Hyper/reqwest/serde) backend and Vue 3 (Pinia, Vue Router, Teleport) frontend, plus a Node.js/Express demo backend (`neode-ui/mock-backend.js`). No new libraries are needed for any of FED-01 through FED-06 — `ScreensaverRing.vue` and `handle_lnd_openchannel` already exist and should be reused, not reimplemented.
|
|
||||||
|
|
||||||
### Core (existing, reused)
|
|
||||||
| Library | Version | Purpose | Why Standard |
|
|
||||||
|---------|---------|---------|--------------|
|
|
||||||
| tokio | (workspace pin) | Async runtime, `interval()`/`spawn()` for the periodic sync loops | Already the project's async foundation |
|
|
||||||
| reqwest | (workspace pin) | HTTP client for FIPS/Tor peer dial and LND REST calls | Already used throughout `fips/dial.rs` and `api/rpc/lnd/` |
|
|
||||||
| serde/serde_json | (workspace pin) | Wire format for `NodeStateSnapshot`, RPC params | Project-wide convention |
|
|
||||||
| Vue 3 + Pinia | (package.json pin) | Frontend reactivity/state | Existing frontend stack |
|
|
||||||
|
|
||||||
**Version verification:** No new packages are being added; skip registry verification per protocol (nothing to verify). If the planner introduces any new crate/npm package during execution, verify it then.
|
|
||||||
|
|
||||||
## Package Legitimacy Audit
|
|
||||||
|
|
||||||
No external packages are being introduced by this phase — this section is not applicable. If a later plan step decides to add a dependency (e.g., a curated public-LSP list requires a small crate), run the Package Legitimacy Gate at that time.
|
|
||||||
|
|
||||||
## Architecture Patterns
|
|
||||||
|
|
||||||
### System Architecture Diagram
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Federation node list (disk) │
|
|
||||||
│ federation/{nodes,removed-nodes}.json │
|
|
||||||
│ NO LOCK — read-modify-write per call │
|
|
||||||
└───────────────┬─────────────────────────┘
|
|
||||||
│ load_nodes() / save_nodes()
|
|
||||||
┌───────────────────────────┼───────────────────────────┬─────────────────────────┐
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌───────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
|
||||||
│ 90s auto-sync │ │ 1800s auto-sync │ │ RPC: sync-state, │ │ RPC: remove-node, │
|
|
||||||
│ loop (server.rs│ │ loop (server.rs │ │ (manual "Sync") │ │ set-trust, join, │
|
|
||||||
│ ~L497) │ │ ~L840) │ │ │ │ peer-joined │
|
|
||||||
└───────┬───────┘ └────────┬──────────┘ └────────┬──────────┘ └───────────┬─────────┘
|
|
||||||
│ sync_with_peer() │ sync_with_peer() │ │
|
|
||||||
│ → update_node_state() │ → update_node_state() │ │
|
|
||||||
└──────────────┬─────────────┴──────────────┬─────────────────┘ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
(race: stale in-memory list from an in-flight sync's earlier load_nodes()
|
|
||||||
overwrites a concurrent remove_node()'s just-saved tombstoned list)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ mesh/mod.rs: purge_federation_peer │
|
|
||||||
│ (peers, messages, contacts, presence)│
|
|
||||||
└───────────────┬───────────────────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ StateManager broadcast → WebSocket │
|
|
||||||
│ → Pinia stores → Federation.vue / │
|
|
||||||
│ Mesh.vue re-render │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
Mesh attachment/parity path (FED-04):
|
|
||||||
Frontend attach flow → mesh.transport-advice → {auto-mesh|choose|tor-only}
|
|
||||||
→ mesh.send-content-inline (small) | mesh.send-content (large, via /api/blob)
|
|
||||||
real daemon: api/rpc/mesh/typed_messages.rs demo: mock-backend.js (mirrors tier logic — DONE)
|
|
||||||
Frontend contacts/reactions/edit/delete
|
|
||||||
real daemon: api/rpc/mesh/typed_messages.rs (contacts-list/save, send-reaction, edit-message, ...)
|
|
||||||
demo: mock-backend.js — contacts-list/save MISSING (404); reaction/edit/delete are no-op acks (GAP)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recommended Project Structure
|
|
||||||
|
|
||||||
No new directories needed. Touch points:
|
|
||||||
```
|
|
||||||
core/archipelago/src/federation/storage.rs # add locking around load/save
|
|
||||||
core/archipelago/src/server.rs # collapse or coordinate the two sync loops
|
|
||||||
core/archipelago/src/api/rpc/lnd/info.rs # extend handle_lnd_getinfo with identity_pubkey/uris
|
|
||||||
core/archipelago/src/federation/types.rs # (maybe) add lightning fields to NodeStateSnapshot
|
|
||||||
core/archipelago/src/api/rpc/federation/handlers.rs # (maybe) new RPC to fetch a peer's LN info
|
|
||||||
neode-ui/mock-backend.js # add mesh.contacts-list/save, stateful reaction/edit/delete
|
|
||||||
neode-ui/src/components/LightningChannelModal.vue # NEW — FED-05 (name TBD by planner)
|
|
||||||
neode-ui/src/components/SendBitcoinModal.vue # FED-06 swap
|
|
||||||
neode-ui/src/components/WalletScanModal.vue # FED-06 swap (secondary paid-tick site)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 1: Federation removal is already "belt and suspenders" — reuse, don't rewrite
|
|
||||||
**What:** `handle_federation_remove_node` (handlers.rs:273) captures the peer's pubkey *before* calling `federation::remove_node`, then after removal calls `mesh::purge_federation_peer` to drop the synthetic mesh contact, its messages, presence, and persisted mesh-contacts entry. `federation::remove_node` (storage.rs:180) already tombstones the DID **before** saving the filtered node list and propagates a tombstone-write failure as an error (fixed in `01cbec27`).
|
|
||||||
**When to use:** This is the correct pattern for FED-01 already. Don't redesign it — the actual gap is the concurrency race in the storage layer underneath it (see Pitfall 1), not the removal logic itself.
|
|
||||||
**Example:**
|
|
||||||
```rust
|
|
||||||
// Source: core/archipelago/src/federation/storage.rs:180-198 (already fixed, 01cbec27)
|
|
||||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
|
||||||
let mut nodes = load_nodes(data_dir).await?;
|
|
||||||
let before = nodes.len();
|
|
||||||
nodes.retain(|n| n.did != did);
|
|
||||||
if nodes.len() == before {
|
|
||||||
anyhow::bail!("No federated node with DID {}", did);
|
|
||||||
}
|
|
||||||
// Tombstone FIRST and propagate failure — a remove whose tombstone
|
|
||||||
// never landed isn't a remove.
|
|
||||||
tombstone_did(data_dir, did).await.context("persist removal tombstone")?;
|
|
||||||
save_nodes(data_dir, &nodes).await?;
|
|
||||||
Ok(nodes)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 2: Transitive sync already respects tombstones — verify, don't re-add protection
|
|
||||||
**What:** `merge_transitive_peers` (sync.rs:120) loads `load_removed_dids()` and skips any hint whose DID is tombstoned, and `handle_federation_peer_joined` (handlers.rs:641) independently rejects a `peer-joined` callback for a tombstoned DID. Both paths that could resurrect a removed node already check the tombstone list.
|
|
||||||
**When to use:** The FED-03 review should write a test that concurrently exercises remove + an in-flight sync (see Pitfall 1) rather than re-deriving the (already-correct) tombstone-check logic.
|
|
||||||
|
|
||||||
### Pattern 3: Demo backend must be a byte-for-byte RPC mirror, not a "close enough" mock
|
|
||||||
**What:** `mock-backend.js`'s `mesh.transport-advice` case (line 4318) explicitly duplicates the daemon's size thresholds (`MESH_AUTO_MAX = 1024`, `MESH_HARD_MAX = 2300`) with a comment pointing at `typed_messages.rs handle_mesh_transport_advice` as the source of truth.
|
|
||||||
**When to use:** Apply the same pattern to `mesh.contacts-list`/`mesh.contacts-save` and to the reaction/edit/delete stubs — read the real handler in `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (lines 1180-1371 for contacts/presence, 637-976 for reply/reaction/receipt/forward, 1065-1180 for edit/delete) and mirror its actual state transitions in `meshStore.dynamic`, not just an `{ok:true}` ack.
|
|
||||||
|
|
||||||
### Anti-Patterns to Avoid
|
|
||||||
- **Unlocked read-modify-write on shared JSON files:** `federation/storage.rs` has none of `load_nodes()`/`save_nodes()` behind a mutex, and writes are a direct `fs::write()` (not atomic temp+rename). Any new federation code must NOT add a third code path that does its own read-modify-write without going through a shared lock — that widens the race window instead of closing it.
|
|
||||||
- **Silent `debug!()` on periodic-loop errors:** Both sync loops in `server.rs` log sync failures at `debug!` level only (not surfaced to the state broadcast, not visible in the UI). FED-02 explicitly requires operator-visible sync errors — don't add a third silent loop; extend the existing ones to persist a `last_sync_error` alongside `last_seen`.
|
|
||||||
- **Reinventing `handle_lnd_openchannel`'s connect-then-open sequence:** It already does `perm=false` synchronous peer connect before opening (with a documented reason: `perm=true` races and fails with "peer is not online"). Reuse it; do not write a second Lightning-channel RPC.
|
|
||||||
- **Assuming `ScreensaverRing` is pre-sized for a 112px success badge:** its `compact` class is 240px (mobile) / 320px (≥768px) — 2-3x the current `.send-success-burst`. Naive drop-in will overflow the modal card; must be explicitly scaled or the surrounding layout redesigned.
|
|
||||||
|
|
||||||
## Don't Hand-Roll
|
|
||||||
|
|
||||||
| Problem | Don't Build | Use Instead | Why |
|
|
||||||
|---------|-------------|-------------|-----|
|
|
||||||
| Connecting to + opening a channel with an LN peer | A new RPC | `lnd.openchannel` (`api/rpc/lnd/channels.rs:238`) | Already validates pubkey format, amount bounds, fee params, and does the connect-before-open sequence correctly with the documented `perm=false` fix |
|
|
||||||
| Paid/success radial visual | A new ring/particle component | `ScreensaverRing.vue` (`compact` size) | Explicitly required by FED-06; also already ships a reduced-motion-friendly animation pattern to copy for consistency |
|
|
||||||
| Peer transport badge (FIPS/Tor) | New transport-tracking logic | `FederatedNode.last_transport`/`last_transport_at` (already written by `record_peer_transport`) | Already ground-truth (records what was actually used, not predicted) — FED-05's peer picker can reuse this field to show reachability |
|
|
||||||
| Federated-peer list for the "trusted nodes" picker | A new RPC | `federation.list-nodes` (already returns `did`, `name`, `onion`, `trust_level`, `last_seen`) | FED-05 only needs to ADD Lightning fields to this payload/peer lookup, not build a parallel peer list |
|
|
||||||
|
|
||||||
**Key insight:** Almost every piece of infrastructure FED-01/02/04/05 need already exists in some form — the gaps are narrow (a missing lock, a missing mock method, a missing struct field) rather than missing subsystems. Resist the urge to redesign the federation sync architecture; patch the specific race and the specific parity/field gaps identified here.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
### Pitfall 1: Unlocked concurrent read-modify-write on `federation/nodes.json` (PRIMARY SUSPECT for FED-01/FED-02)
|
|
||||||
**What goes wrong:** A federated node the operator removes reappears after "some sync cycles," with no error anywhere — exactly the symptom reported. `federation::remove_node()` and `federation::sync::update_node_state()` (called from `sync_with_peer()`) both do `load_nodes()` → mutate in memory → `save_nodes()` with zero mutex and a non-atomic `fs::write()`. Two async tasks (e.g., the 90s auto-sync loop mid-flight for peer X, and a `federation.remove-node` RPC for the same peer X arriving concurrently) can interleave: the sync task's `load_nodes()` snapshot (taken before the removal) still contains X; the removal completes and saves a list without X; the sync task then finishes and calls `save_nodes()` with its stale in-memory list, silently restoring X.
|
|
||||||
**Why it happens:** No `Mutex`/`RwLock` guards the federation JSON files, and there are TWO independent periodic sync loops (`server.rs` ~line 497, every 90s; ~line 840, every 1800s — the second loop's comment even says "every 30 min" while the interval literal is `Duration::from_secs(1800)`, i.e. that arithmetic is correct but the redundancy with the 90s loop is not otherwise explained or justified anywhere in the code) plus manual "Sync All" and remove/join/set-trust RPCs, all writing the same file.
|
|
||||||
**How to avoid:** Add a per-data-dir `tokio::sync::Mutex<()>` (or an `Arc<Mutex<Vec<FederatedNode>>>` cache) that every `federation::storage` read-modify-write function acquires for the duration of its load+mutate+save; consider switching `save_nodes` to atomic temp-file+rename to avoid partial-write corruption on crash. Separately, evaluate collapsing the two periodic sync loops into one (the 90s loop already does everything the 1800s loop does, plus asymmetry self-heal) — the 1800s loop appears vestigial/redundant and doubles the race exposure for no described benefit.
|
|
||||||
**Warning signs:** `tests/multinode/smoke.sh`'s "removed-node tombstone" section (already covers the transitive-reappear case) intermittently fails only under load/timing variance, or a removed node's `last_seen` timestamp updates *after* a `federation.remove-node` call succeeded — that's the race manifesting as reappearance without any logged error.
|
|
||||||
|
|
||||||
### Pitfall 2: Trusting `CONCERNS.md` as current state for this phase
|
|
||||||
**What goes wrong:** Re-fixing an already-fixed bug (tombstone-write-swallowed was fixed in `01cbec27`, 2026-07-02) wastes the FED-03 review's time and risks reintroducing a regression if the "fix" reverts working code.
|
|
||||||
**Why it happens:** `CONCERNS.md` was generated 2026-07-29 from a static codebase snapshot/analysis pass that in at least two documented cases (tombstone swallow, DID-join-without-verification) predates fixes already on `main`.
|
|
||||||
**How to avoid:** For every `CONCERNS.md` federation/mesh item, `git log -p` the referenced file/line range before deciding it's still open. Two items already verified fixed in this research: "Federation node removal tombstone gap" (fixed `01cbec27`) and part of "Federation DID validation incomplete" (the `peer-joined` RPC does require and verify an ed25519 signature — `handlers.rs:588-607`). The remaining un-verified part of that concern — no proof-of-ownership check on the *original* DID mint, i.e. can anyone claim any DID string on first contact — may still be valid; verify it during the review rather than assuming either way.
|
|
||||||
**Warning signs:** A "finding" in the FED-03 review that exactly matches a `CONCERNS.md` bullet without a fresh code read is a signal to re-verify before filing it.
|
|
||||||
|
|
||||||
### Pitfall 3: Demo mock silently no-ops instead of erroring on unmirrored RPCs
|
|
||||||
**What goes wrong:** `mesh.contacts-list`/`mesh.contacts-save` are called live from `Mesh.vue` (lines 113, 896) but have no case in `mock-backend.js`'s switch — they fall through to the `default` case which returns a proper JSON-RPC error (`Method not found`), but the frontend call sites wrap them in `try {} catch { /* non-fatal */ }`, so the failure is invisible during manual demo testing unless you watch the browser console or server log (`console.log('[RPC] Unknown method: ...')`).
|
|
||||||
**Why it happens:** New frontend RPC call sites get added over time; `mock-backend.js` parity is manual and easy to miss for methods that aren't on the "main" flow (aliasing a peer is a secondary action, not part of onboarding/attach-file).
|
|
||||||
**How to avoid:** Grep `neode-ui/src/api/rpc-client.ts` for every `mesh.*`/`federation.*` method string and cross-reference against `mock-backend.js`'s switch cases as an explicit FED-03/FED-04 checklist item, not just the attachment-send path already fixed.
|
|
||||||
**Warning signs:** Browser console shows `[RPC] Unknown method: mesh.contacts-list` while testing the demo at `:8100`.
|
|
||||||
|
|
||||||
### Pitfall 4: `ScreensaverRing`'s size classes don't have a "success-badge" variant
|
|
||||||
**What goes wrong:** Dropping `<ScreensaverRing size="compact" />` directly into `.send-success-burst` (currently 112px) either overflows the card or looks disproportionate at 240-320px without adjusting the surrounding layout.
|
|
||||||
**Why it happens:** `ScreensaverRing.vue`'s two size classes (`viz-ring-default`, `viz-ring-compact`) were designed for full-screen screensaver and settings-panel contexts (`SystemDangerZone.vue`), not for an inline modal success pane.
|
|
||||||
**How to avoid:** Either (a) wrap the component in a container with `transform: scale(0.5)` (112/240 ≈ 0.47) and compensate for the transform not affecting layout box size (use negative margins or a fixed wrapping box), or (b) add a third `compact-sm`/`badge` size variant to `ScreensaverRing.vue` sized for this use case (cleaner, and reusable for `WalletScanModal.vue`'s `.success-ring` too). Confirm the choice with a UI-spec/sketch before implementation given this affects two components.
|
|
||||||
**Warning signs:** Visual QA on `:8100` shows the ring clipped by the modal's `max-h-[90vh] overflow-y-auto` container or the checkmark badge floating disconnected from the ring's visual center.
|
|
||||||
|
|
||||||
### Pitfall 5: FED-05 has no backend field for a peer's Lightning identity
|
|
||||||
**What goes wrong:** Building the "trusted nodes by hostname, one-click channel open" UI before the backend can supply a federated peer's LN `pubkey`/`host:port` results in a UI that can list *names* but has nothing to pass to `lnd.openchannel`.
|
|
||||||
**Why it happens:** `NodeStateSnapshot` (the payload `federation.get-state`/sync exchanges) has no Lightning fields at all — it was designed for app/CPU/mem/tor status, not payment-channel metadata.
|
|
||||||
**How to avoid:** Plan FED-05 backend-first: (1) extend `handle_lnd_getinfo` to parse and return `identity_pubkey` + `uris` from LND's real `/v1/getinfo` response (both fields already exist in LND's REST API — the daemon's `LndGetInfoResponse` struct just doesn't deserialize them yet), (2) add optional `lightning_pubkey`/`lightning_uri` fields to `NodeStateSnapshot` so a synced peer's info includes it (defaulted via `#[serde(default)]` for backward compat, matching every other optional field in that struct), (3) decide and scope the "public nodes" browse/request feature — no existing data source; recommend a small curated static list (documented, versioned) rather than a live LN graph query (`DescribeGraph` is heavy and not currently proxied anywhere in this codebase) unless the user specifically wants live graph browsing.
|
|
||||||
**Warning signs:** A plan step that starts building `LightningChannelModal.vue` before a corresponding backend RPC/field change is scoped — check the plan's task ordering.
|
|
||||||
|
|
||||||
## Code Examples
|
|
||||||
|
|
||||||
### Reuse: opening a channel (backend already correct)
|
|
||||||
```rust
|
|
||||||
// Source: core/archipelago/src/api/rpc/lnd/channels.rs:238-336 (excerpted)
|
|
||||||
// Params: { pubkey: <66-hex>, amount: <sats>, address?: <host:port>, private?, target_conf?, sat_per_vbyte? }
|
|
||||||
// Validates pubkey format + amount bounds (20,000..=16,777,215 sats) before touching LND.
|
|
||||||
// Connects to the peer synchronously (perm=false) before opening so "peer not online" is
|
|
||||||
// surfaced deterministically instead of racing the open.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reuse: transport badge already ground-truth per peer
|
|
||||||
```rust
|
|
||||||
// Source: core/archipelago/src/federation/storage.rs:120-147
|
|
||||||
// record_peer_transport() writes last_transport/last_transport_at after every
|
|
||||||
// successful PeerRequest — the FED-05 peer picker can show "reachable via FIPS"
|
|
||||||
// / "reachable via Tor" per trusted node without any new plumbing.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gap: demo mesh chat action stubs don't mutate state
|
|
||||||
```javascript
|
|
||||||
// Source: neode-ui/mock-backend.js:4479-4490 (current — needs to become stateful)
|
|
||||||
case 'mesh.send-reaction':
|
|
||||||
case 'mesh.send-reply':
|
|
||||||
case 'mesh.send-read-receipt':
|
|
||||||
case 'mesh.edit-message':
|
|
||||||
case 'mesh.delete-message':
|
|
||||||
case 'mesh.forward-message':
|
|
||||||
case 'mesh.send-channel':
|
|
||||||
case 'mesh.refresh':
|
|
||||||
case 'mesh.reboot-radio': {
|
|
||||||
return res.json({ result: { ok: true, sent: true } }) // no meshStore.dynamic mutation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## State of the Art
|
|
||||||
|
|
||||||
| Old Approach | Current Approach | When Changed | Impact |
|
|
||||||
|--------------|------------------|---------------|--------|
|
|
||||||
| Tombstone write silently dropped (`let _ = tombstone_did(...)`) | Tombstone write propagated as a hard error, written before the node-list save | 2026-07-02, `01cbec27` | A failed tombstone write now fails the whole remove — matches FED-01's "a failed removal surfaces an error" requirement already, at the single-call level (the remaining gap is the cross-call race in Pitfall 1) |
|
|
||||||
| Mesh attachment send: demo threw "Method not found" and force-opened a demo-only chooser modal | `mock-backend.js` implements the same RPC surface + mirrors the real size-tier logic | 2026-07-29, `c2ce71c6` | FED-04's core attachment-parity requirement is met; remaining gaps are contacts and reaction/edit/delete (see Pitfall 3) |
|
|
||||||
|
|
||||||
**Deprecated/outdated:** None specific to this phase's tech; no framework/library version churn involved.
|
|
||||||
|
|
||||||
## Assumptions Log
|
|
||||||
|
|
||||||
| # | Claim | Section | Risk if Wrong |
|
|
||||||
|---|-------|---------|---------------|
|
|
||||||
| A1 | The unlocked read-modify-write race on `federation/nodes.json` (Pitfall 1) is the primary cause of the user-reported "nodes reappear / sync issues" — this is a code-derived hypothesis, not confirmed via a reproduced failure in this research session | Summary, Pitfall 1 | If wrong, the planner should still fix the race (it's a real bug regardless) but should budget review time for other causes too — start FED-03 with a broader look, not a narrow patch-and-close on this one hypothesis |
|
|
||||||
| A2 | The 1800s periodic federation sync loop (`server.rs` ~line 840) is redundant given the 90s loop and safe to remove/collapse | Pitfall 1, Anti-Patterns | If a maintainer added it for a specific reason not documented in the surrounding comments (e.g. covering a case the 90s loop misses), removing it could regress that unstated behavior — confirm via `git log -p` / git blame on that block before deleting |
|
|
||||||
| A3 | "Public nodes" for channel-open browse/request (FED-05) should be a small curated static list rather than a live LN network graph query | Pitfall 5 | If the user actually wants live graph discovery, the curated-list approach under-delivers; this needs explicit user confirmation before FED-05 backend work starts |
|
|
||||||
| A4 | `ScreensaverRing`'s size mismatch with `.send-success-burst` should be solved with a CSS scale-down rather than a new component size variant | Pitfall 4 | Either approach works technically; scale-down is faster but may look slightly different under `prefers-reduced-motion`; a new size variant is cleaner but touches the shared component. Low risk either way — a UI sketch/spec pass can decide before implementation |
|
|
||||||
| A5 | The remaining "Federation DID validation incomplete" concern (no proof-of-ownership check on first DID mint) is still an open gap, not yet fixed like its sibling claims | Pitfall 2 | Not independently re-verified in this session (only the peer-joined signature check was confirmed); FED-03 should explicitly re-check this specific sub-claim before filing or dismissing it |
|
|
||||||
|
|
||||||
**If this table is empty:** N/A — see rows above.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Is the two-loop federation sync redundancy intentional?**
|
|
||||||
- What we know: Both loops call `sync_with_peer` over all `Trusted`/`Observer` nodes; only the 90s loop does the "asymmetry self-heal" `notify_join` re-assertion; the 1800s loop additionally calls `refresh_federation_mesh_peers()` after its full pass (the 90s loop does not).
|
|
||||||
- What's unclear: Whether the 1800s loop's `refresh_federation_mesh_peers()` call covers a gap the 90s loop leaves (e.g. name/roster propagation to mesh chat), which would mean simply deleting it regresses something.
|
|
||||||
- Recommendation: `git log -p` / blame both loop-insertion commits during FED-03; if the mesh-peer-refresh behavior is the only unique value of the 1800s loop, move that single call into the 90s loop's completion and delete the 1800s loop entirely, closing half the race window.
|
|
||||||
|
|
||||||
2. **What UI/UX should "browse/request channels with public nodes" (FED-05) actually look like?**
|
|
||||||
- What we know: No existing data source; `lnd.openchannel` supports a manual pubkey+address entry today (a user could theoretically paste a public node's URI already, just with no picker/browse UI).
|
|
||||||
- What's unclear: Whether "public nodes" means (a) a curated list Archipelago ships/updates, (b) a live query against some LSP directory API, or (c) simply a well-labeled manual-paste field with format help (lowest-effort, matches what the backend already supports).
|
|
||||||
- Recommendation: Flag for `/gsd-discuss-phase` or a direct user check-in before FED-05 planning — this is a scope decision, not a technical one.
|
|
||||||
|
|
||||||
3. **Does `federation.get-state`'s `federated_peers` hint list need a Lightning field, or should peer LN info be a separate on-demand RPC?**
|
|
||||||
- What we know: `NodeStateSnapshot.federated_peers` already carries a lightweight `FederationPeerHint` (did/pubkey/onion/name/fips_npub) shared during sync; adding `lightning_uri` there means every synced peer's LN info is cached locally without an extra round-trip.
|
|
||||||
- What's unclear: Whether peers want to opt out of advertising their LN URI transitively (privacy consideration, similar to the existing `shared_location` opt-in pattern for lat/lon).
|
|
||||||
- Recommendation: Follow the `shared_location` precedent (`Option<(f64,f64)>` only sent when the node opts in via `server.set-location`) — add an explicit opt-in setting for Lightning URI sharing rather than defaulting it on, since exposing a payment channel target more broadly than intended has real-money implications.
|
|
||||||
|
|
||||||
## Environment Availability
|
|
||||||
|
|
||||||
| Dependency | Required By | Available | Version | Fallback |
|
|
||||||
|------------|------------|-----------|---------|----------|
|
|
||||||
| Rust toolchain / cargo (from `core/`) | FED-01/02/03/04 backend work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
|
||||||
| Node.js / npm (`neode-ui/`) | FED-04/05/06 frontend + mock-backend.js work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
|
||||||
| `:8100` dev preview proxying to archi-dev | FED-05/06 required verification step per phase description | Not probed this session — verify at execution time per `docs/../reference_neode_ui_dev_testing.md` (mock=5959, pw password123) | — | — |
|
|
||||||
| LND REST API (`LND_REST_BASE_URL`, local macaroon) | FED-05 `lnd.getinfo` extension + `lnd.openchannel` reuse | Assumed present on real nodes per existing `channels.rs`/`info.rs` code; demo backend has no real LND — FED-05 UI must be exercised against archi-dev (real LND) per phase description, not the pure-mock demo | — | Demo-only mock stub for `lnd.getinfo` identity fields if archi-dev is unavailable during a work session |
|
|
||||||
| `tests/multinode/smoke.sh` | Regression coverage for FED-01/02 fix | Present, already covers removed-node tombstone + transitive-reappear scenarios; does NOT currently exercise the concurrent-race scenario (Pitfall 1) | — | Extend smoke.sh with a concurrent remove+sync test, or add a Rust-level `#[tokio::test]` in `federation/storage.rs` that spawns concurrent remove/save calls |
|
|
||||||
|
|
||||||
**Missing dependencies with no fallback:** None identified — this phase is code-only, no new external services.
|
|
||||||
|
|
||||||
**Missing dependencies with fallback:** LND-backed FED-05 verification (see row above) — use archi-dev per phase instructions; demo-only stubbing is a fallback if archi-dev is temporarily unavailable, but the phase's own success criteria require verification against archi-dev before deploy, so this fallback should not be treated as sufficient sign-off.
|
|
||||||
|
|
||||||
## Validation Architecture
|
|
||||||
|
|
||||||
### Test Framework
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Framework (backend) | `cargo test` (Rust, workspace root `core/`) — federation/storage.rs and federation/sync.rs already have `#[tokio::test]` unit coverage |
|
|
||||||
| Framework (frontend) | Vitest (`neode-ui/vitest.config.ts`, `vitest run`) |
|
|
||||||
| Config file | `core/Cargo.toml` (workspace); `neode-ui/vitest.config.ts` |
|
|
||||||
| Quick run command | `cd core && cargo test -p archipelago federation:: --lib` (backend); `cd neode-ui && npx vitest run src/components/__tests__/` (frontend, scope to touched files) |
|
|
||||||
| Full suite command | `cd core && CARGO_INCREMENTAL=0 cargo test` (backend, full); `cd neode-ui && npm run test` (frontend, full); `tests/multinode/smoke.sh` (cross-node, requires 2+ live nodes, run on-node per CLAUDE.md gate policy) |
|
|
||||||
|
|
||||||
### Phase Requirements → Test Map
|
|
||||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
||||||
|--------|----------|-----------|-------------------|-------------|
|
|
||||||
| FED-01 | Removed node never reappears, incl. under concurrent sync | unit + integration | `cargo test -p archipelago federation::storage::tests` (existing) + NEW concurrent-race test | ✅ existing tests / ❌ Wave 0 for the new race test |
|
|
||||||
| FED-01 | Failed removal surfaces an error, not a silent no-op | unit | `cargo test -p archipelago federation::storage::tests::test_remove_nonexistent_node_errors` (existing, covers the "not found" case; add one for a simulated tombstone-write I/O failure) | ✅ existing / ❌ Wave 0 for I/O-failure case |
|
|
||||||
| FED-02 | Sync converges; fleet nodes agree on node list | integration (multinode) | `tests/multinode/smoke.sh` section "federation pairing" + "removed-node tombstone" (existing) | ✅ |
|
|
||||||
| FED-02 | Sync errors are operator-visible | manual / UI | No automated test yet — requires a UI element (e.g. per-node "last sync error" badge) that doesn't exist yet | ❌ Wave 0 (needs the field to exist first) |
|
|
||||||
| FED-03 | Structured review findings fixed or deferred with reason | N/A (process requirement) | N/A — tracked via the plan's findings list, not a single automated test | — |
|
|
||||||
| FED-04 | Attachment send parity demo vs real | manual (visual) + existing `mock-backend.js` logic mirrors daemon tier thresholds | Manual walk-through on `:8100` per phase description; consider a Vitest test asserting `mesh.transport-advice` tier boundaries match `MESH_AUTO_MAX`/`MESH_HARD_MAX` constants | ❌ Wave 0 (no existing frontend test pins these thresholds) |
|
|
||||||
| FED-04 | Contacts list/save + reaction/edit/delete parity | manual + NEW mock-backend.js stateful behavior | Manual on `:8100`; no existing automated coverage of `mock-backend.js` behavior (it's a dev tool, not covered by `npm run test`) | ❌ Wave 0 if automated coverage is wanted; otherwise manual-only is acceptable for a demo shim |
|
|
||||||
| FED-05 | Own node Lightning URI is shareable | unit (backend) + manual (UI) | NEW `cargo test` for `handle_lnd_getinfo`'s identity_pubkey/uris parsing (mock LND response fixture); manual UI check on archi-dev | ❌ Wave 0 |
|
|
||||||
| FED-05 | Trusted-node picker + channel open flow | manual (UI, requires archi-dev + a live peer) | Manual per phase description ("tested live on the :8100 dev preview against archi-dev") | ❌ Wave 0 — inherently a live/manual check per the phase's own success criteria |
|
|
||||||
| FED-06 | Paid-tick animation matches screensaver ring everywhere it appears | manual (visual) | Manual visual check of `SendBitcoinModal.vue` + `WalletScanModal.vue` on `:8100` | ❌ Wave 0 — visual-only requirement, no meaningful automated assertion beyond "component renders" |
|
|
||||||
|
|
||||||
### Sampling Rate
|
|
||||||
- **Per task commit:** Backend: `cargo test -p archipelago federation:: mesh::` (scoped). Frontend: `npx vitest run` scoped to touched component test files, or a full quick run if none exist yet for touched files.
|
|
||||||
- **Per wave merge:** Full `cargo test` (backend) + `npm run test` (frontend).
|
|
||||||
- **Phase gate:** Full backend + frontend suites green, plus `tests/multinode/smoke.sh` federation sections green on a real 2-node pair, plus a manual FED-05/FED-06 walkthrough on `:8100` against archi-dev before any deploy (per phase description, "fixed there before any deploy").
|
|
||||||
|
|
||||||
### Wave 0 Gaps
|
|
||||||
- [ ] New `#[tokio::test]` in `core/archipelago/src/federation/storage.rs` (or a new integration test) that spawns concurrent `remove_node()` + `update_node_state()`/`sync_with_peer`-equivalent calls against the same `data_dir` and asserts the removed node stays removed — this is the regression test for Pitfall 1 and does not exist today.
|
|
||||||
- [ ] Test/fixture for `handle_lnd_getinfo` parsing `identity_pubkey`/`uris` from a mocked LND `/v1/getinfo` JSON response (FED-05) — no existing test touches this handler's response shape.
|
|
||||||
- [ ] Decide whether `mock-backend.js` behavior warrants automated (Vitest/Playwright-against-mock) coverage, or manual-only is acceptable given it's a dev-preview tool, not shipped code — recommend manual-only unless the team already has a pattern for testing the mock backend elsewhere (none found in this research).
|
|
||||||
- [ ] Framework install: none — all frameworks (`cargo test`, Vitest) are already configured and running.
|
|
||||||
|
|
||||||
## Security Domain
|
|
||||||
|
|
||||||
### Applicable ASVS Categories
|
|
||||||
|
|
||||||
| ASVS Category | Applies | Standard Control |
|
|
||||||
|---------------|---------|-----------------|
|
|
||||||
| V2 Authentication | Partial | Federation peer identity is DID/ed25519-key-based, not password auth; `peer-joined`/`peer-did-changed`/`peer-address-changed` all require and verify an ed25519 signature over a canonical message before mutating state — already correct, verify no new RPC bypasses this |
|
|
||||||
| V3 Session Management | No | Not applicable — federation/mesh RPCs are peer-signed, not session-cookie based |
|
|
||||||
| V4 Access Control | Yes | `is_peer_allowed_path()` (`server.rs:1270`, tested at `server.rs:2075+`) restricts which HTTP paths a peer-only listener will serve — any new FED-05 RPC (e.g. "fetch peer's Lightning URI") that's meant to be peer-reachable must be added to this allow-list explicitly, not left to fall through |
|
|
||||||
| V5 Input Validation | Yes | `lnd.openchannel` already validates pubkey format (66-hex) and amount bounds server-side (`channels.rs:252-268`) — reuse, and apply the same rigor to any new Lightning-URI-sharing field (validate the URI format before persisting/displaying it) |
|
|
||||||
| V6 Cryptography | Yes | ed25519 signature verification via `identity::NodeIdentity::verify` — never hand-roll signature checks; reuse this existing verification path if FED-05 needs to authenticate a peer's advertised Lightning info |
|
|
||||||
|
|
||||||
### Known Threat Patterns for this stack
|
|
||||||
|
|
||||||
| Pattern | STRIDE | Standard Mitigation |
|
|
||||||
|---------|--------|---------------------|
|
|
||||||
| Federation peer spoofing a DID they don't control | Spoofing | ed25519 signature verification (already implemented for join/address-change/did-rotation — confirm any new FED-05 peer-info exchange follows the same pattern) |
|
|
||||||
| Concurrent-write race corrupting/reverting federation state (Pitfall 1) | Tampering (unintentional, but security-relevant since it undermines the "removal sticks" guarantee — a removed/untrusted peer regaining federation membership is a real access-control regression) | Add locking around the storage layer (see Pitfall 1's fix) |
|
|
||||||
| Unbounded transitive federation exposure (a Trusted peer's peer list auto-added as Observer) | Elevation of Privilege (bounded) | Already mitigated — `merge_transitive_peers` only runs for `Trusted`-level sources and only adds new peers as `Observer` (never auto-escalates to `Trusted`); this is intentional and correct, don't loosen it |
|
|
||||||
| Advertising this node's Lightning payment-channel target more broadly than intended (FED-05 new surface) | Information Disclosure | Follow the existing `shared_location` opt-in pattern — do not default Lightning URI sharing to "on" for all federated peers (see Open Question 3) |
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
### Primary (HIGH confidence)
|
|
||||||
- `core/archipelago/src/federation/storage.rs`, `sync.rs`, `types.rs`, `invites.rs` — read directly, current `main`
|
|
||||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` — read directly, current `main`
|
|
||||||
- `core/archipelago/src/server.rs` (periodic sync loop sections, `is_peer_allowed_path`) — read directly
|
|
||||||
- `core/archipelago/src/mesh/mod.rs` (`purge_federation_peer`, `upsert_federation_peer`, `seed_federation_peers_into_mesh`) — read directly
|
|
||||||
- `core/archipelago/src/api/rpc/lnd/channels.rs`, `info.rs` — read directly
|
|
||||||
- `core/archipelago/src/fips/dial.rs` — read directly
|
|
||||||
- `neode-ui/mock-backend.js` (mesh RPC switch cases) — read directly, current `main` (post commit `c2ce71c6`)
|
|
||||||
- `neode-ui/src/views/Federation.vue`, `neode-ui/src/api/rpc-client.ts`, `neode-ui/src/components/ScreensaverRing.vue`, `neode-ui/src/components/Screensaver.vue`, `neode-ui/src/components/SendBitcoinModal.vue`, `neode-ui/src/components/WalletScanModal.vue`, `neode-ui/src/views/Mesh.vue` — read directly
|
|
||||||
- `git log -p` on `core/archipelago/src/federation/storage.rs` (commit `01cbec27`) and `git show c2ce71c6` — verified fix history directly, not from documentation
|
|
||||||
- `tests/multinode/smoke.sh` — read directly for existing federation test coverage
|
|
||||||
- `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/CONCERNS.md` — project-provided context (CONCERNS.md's federation claims were then verified/refuted against live code per Pitfall 2)
|
|
||||||
|
|
||||||
### Secondary (MEDIUM confidence)
|
|
||||||
- None — this research relied entirely on direct codebase reads and git history, not external web sources, since the phase is about hardening this specific project's existing code rather than adopting new external technology.
|
|
||||||
|
|
||||||
### Tertiary (LOW confidence)
|
|
||||||
- None.
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
**Confidence breakdown:**
|
|
||||||
- Standard stack: HIGH — no new dependencies; existing stack confirmed by direct file reads
|
|
||||||
- Architecture (FED-01/02/03/04): HIGH — read the actual implementation, confirmed fix history via git log, identified a concrete unverified race condition with file:line citations
|
|
||||||
- Architecture (FED-05): MEDIUM — greenfield UI/RPC surface; confirmed what's missing (no existing Lightning-URI field/RPC) but the design (opt-in sharing, public-nodes scope) needs a user decision, not just engineering judgment
|
|
||||||
- Pitfalls: HIGH for Pitfalls 1-3 (backend/demo, code-verified); MEDIUM for Pitfalls 4-5 (frontend sizing and FED-05 scope, judgment calls flagged in Assumptions Log)
|
|
||||||
|
|
||||||
**Research date:** 2026-07-29
|
|
||||||
**Valid until:** 2026-08-12 (14 days — this is a fast-moving area of an actively-developed codebase; other agents were committing federation/mesh-adjacent changes during this very research session, per the mock-backend.js commit observed mid-session)
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 1
|
|
||||||
slug: federation-mesh-hardening
|
|
||||||
status: draft
|
|
||||||
shadcn_initialized: false
|
|
||||||
preset: none
|
|
||||||
created: 2026-07-29
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 1 — UI Design Contract
|
|
||||||
|
|
||||||
> Visual and interaction contract for the two UI-facing requirements in this phase:
|
|
||||||
> **FED-05** (inter-node Lightning channel-opening UX) and **FED-06** (on-brand paid-tick
|
|
||||||
> animation). The rest of Phase 1 (FED-01–04) is backend/parity work with no new UI surface.
|
|
||||||
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design System
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Tool | none — no `components.json` found; project is **Vue 3**, and shadcn/ui does not support Vue (React-only), so the shadcn init gate does not apply here. Registry safety gate: not applicable. |
|
|
||||||
| Preset | not applicable |
|
|
||||||
| Component library | none — hand-authored Tailwind utilities + a custom "glass" CSS system (`glass-card`, `glass-button`, `glass-button-warning/danger/success`, `input-glass`, `alert-error/warning/info`, `BaseModal.vue`) defined in `neode-ui/src/style.css` and reused project-wide |
|
|
||||||
| Icon library | none — inline hand-authored SVG, 24×24 viewBox, `stroke-width="2"` outline style (heroicons-esque but not the package). The bolt path `M13 10V3L4 14h7v7l9-11h-7z` is already the house Lightning icon (used in `Server.vue`, `HomeWalletCard.vue`, `Web5Wallet.vue`) — reuse it verbatim for any new Lightning iconography in FED-05, do not source a new icon. |
|
|
||||||
| Font | Avenir Next (`font-sans`, body/UI text), Montserrat 700/800 (`font-archipelago`, headers only — not used in modals) |
|
|
||||||
|
|
||||||
**Modal contract (hard rule, repeated user complaint):** Every new modal in this phase MUST use `BaseModal.vue` (already wraps `Teleport to="body"` + full-screen `bg-black/60 backdrop-blur-md` backdrop + column layout with pinned header/footer and scrolling body) or, if a bespoke modal is unavoidable, MUST replicate that exact `<Teleport to="body">` + `fixed inset-0` + `@click.self="close"` pattern. Never nest a modal inside a `transform`-affected ancestor (glass-panel `translateZ` layers trap `position:fixed`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spacing Scale
|
|
||||||
|
|
||||||
Declared values (must be multiples of 4) — matches `tailwind.config.js`'s existing 4px-grid `spacing` tokens (`1`=4px … `8`=32px) plus standard Tailwind rem multiples used throughout the codebase for larger gaps:
|
|
||||||
|
|
||||||
| Token | Value | Usage |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| xs | 4px | Icon-to-label gaps, badge padding |
|
|
||||||
| sm | 8px | Compact row spacing, `gap-2` |
|
|
||||||
| md | 16px | Default element spacing, `p-4` card padding |
|
|
||||||
| lg | 24px | Section padding, `mb-6` between panel sections |
|
|
||||||
| xl | 32px | Layout gaps between major picker columns |
|
|
||||||
| 2xl | 48px | `py-12` empty-state vertical padding |
|
|
||||||
| 3xl | 64px | Not used by this phase's new elements |
|
|
||||||
|
|
||||||
Exceptions: 44px minimum touch target on all new interactive buttons (global rule already enforced in `style.css` for mobile — the "Copy URI" / "Open Channel" / "Request Channel" buttons inherit `min-height: 44px` from `.glass-button` automatically, no override needed).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
Scoped to this phase's new elements only (existing typography elsewhere is unchanged). Exactly two weights govern this phase's new elements — 400 and 600; Label and Body are differentiated from each other by size and color (not weight), matching how `NodeList.vue` already distinguishes node-name text from badge/hint text:
|
|
||||||
|
|
||||||
| Role | Size | Weight | Line Height |
|
|
||||||
|------|------|--------|-------------|
|
|
||||||
| Label | 12px (`text-xs`) | 400 (regular), `text-white/60` | 1.4 |
|
|
||||||
| Body | 14px (`text-sm`) | 400 (regular), `text-white` | 1.5 |
|
|
||||||
| Heading | 20px (`text-xl`) | 600 (semibold) | 1.3 |
|
|
||||||
|
|
||||||
Heading is pinned to `text-xl` (20px), not a range — this matches `BaseModal.vue`'s own `<h3 class="text-xl font-semibold">` title (the component every new modal in this phase must use per the Modal contract above) and `WalletScanModal.vue`'s pane title, i.e. the size the existing house modals actually use most for their titles. Modal titles ("Open Lightning Channel", "Request Channel") use Heading; node names use Body (`text-white`); URI strings, badges, and helper/meta text use Label (`text-white/60`) per the existing `LightningChannelsPanel.vue`/`NodeList.vue` convention.
|
|
||||||
|
|
||||||
**Inherited — not governed by this contract:** The `SendBitcoinModal.vue`/`WalletScanModal.vue` success-amount numerals (e.g. `12,345 sats`, `text-5xl font-black` — 48px / weight 800) are pre-existing, unchanged display text. FED-06 only replaces the ring graphic behind/around that text, never the text itself, so this weight/size falls outside the phase's new-elements typography contract above and is not counted toward its weight budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color
|
|
||||||
|
|
||||||
| Role | Value | Usage |
|
|
||||||
|------|-------|-------|
|
|
||||||
| Dominant (60%) | `#000000` + `rgba(0,0,0,.35–.65)` | Page background, `.glass`/`.glass-card` surfaces |
|
|
||||||
| Secondary (30%) | `rgba(0,0,0,.65)` blur(18px) card, `rgba(255,255,255,.05–.08)` nested rows | Modal cards, picker list rows (`bg-black/20` per-node rows, `bg-white/5` nested detail blocks) |
|
|
||||||
| Accent (10%) | Archipelago orange `#fb923c` / `rgba(251,146,60,*)` | **Reserved for:** the "Open Channel" / "Request Channel" / "Copy Lightning URI" primary CTA buttons (`.glass-button-warning`), the Lightning bolt icon fill, focus-visible glow rings, the active picker-tab underline (mirrors existing `.mode-switcher-btn-active` treatment) |
|
|
||||||
| Destructive | `#ef4444` family (`.glass-button-danger`) | Not used by FED-05 v1 (no destructive action ships this phase — channel *close* is existing, out-of-scope UI in `LightningChannelsPanel.vue`); declared for consistency if a future "revoke URI sharing" action is added |
|
|
||||||
|
|
||||||
**Inherited semantic colors (pre-existing house convention, unchanged by this phase, NOT part of the 10% accent budget):**
|
|
||||||
- Success/paid emerald `#4ade80` text / `rgba(16,185,129,*)` fills — the paid-tick's center badge and "SENT"/amount numerals (FED-06 keeps this palette; only the surrounding ring geometry changes).
|
|
||||||
- Info blue `#60a5fa` — FIPS/Tor transport badges already shown next to trusted-node rows (`NodeList.vue`'s `transportBadge`); reused as-is in the FED-05 trusted-node picker rows, not introduced by this phase.
|
|
||||||
|
|
||||||
Accent reserved for: **primary Lightning-channel action buttons, the Lightning bolt icon, focus rings, and the active picker-tab indicator only** — never for body text, card backgrounds, or informational badges.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Copywriting Contract
|
|
||||||
|
|
||||||
| Element | Copy |
|
|
||||||
|---------|------|
|
|
||||||
| Primary CTA — own URI | **"Copy Lightning URI"** (copy-to-clipboard button; on success the label flips to **"Copied!"** for ~2s, mirroring `SendBitcoinModal.vue`'s existing `copyDetail`/`Copied!` pattern — do not invent a new copy-feedback idiom) |
|
|
||||||
| Primary CTA — trusted federated node | **"Open Channel"** (one-click; matches the verb already used in `LightningChannelsPanel.vue`'s existing Open Channel button/modal) |
|
|
||||||
| Primary CTA — meshed Lightning peer | **"Request Channel"** (opens the request flow reusing `PeerRequestModal.vue`'s pattern — optional message field, "Send Request" submit button, `sending` → **"Sending…"** busy label — do not build a new request-modal component from scratch) |
|
|
||||||
| Manual fallback entry point | **"Paste URI Manually"** (reveals a `Peer URI` input, placeholder `pubkey@host:port`, helper text `Format: pubkey@host:port` — verbatim reuse of `LightningChannelsPanel.vue`'s existing field copy) |
|
|
||||||
| Empty state heading | **"No Lightning peers yet"** |
|
|
||||||
| Empty state body | **"Add a federated node or connect with a meshed peer running Lightning to open a channel directly — or paste a peer's URI manually below."** |
|
|
||||||
| Error state | **"Couldn't reach that peer — check they're online and try again."** (tone/placement mirrors the existing `openError`/`alert-error` treatment in `LightningChannelsPanel.vue`; LND "still starting up" transient errors reuse that same component's amber `isStartupNotice` treatment rather than the red error style) |
|
|
||||||
| Destructive confirmation | Not applicable — FED-05 v1 ships open/request flows only, no destructive action |
|
|
||||||
| FED-06 copy | Not applicable — pure visual swap. Existing "SENT" / success-amount / "Done" button copy in `SendBitcoinModal.vue` and `WalletScanModal.vue` is unchanged; only the ring graphic behind the checkmark changes. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Considerations
|
|
||||||
|
|
||||||
Applicable state considerations resolved: 13 covered, 3 backstop, 0 unresolved.
|
|
||||||
|
|
||||||
| Category | Element(s) | Status | Resolution / Reason |
|
|
||||||
|----------|------------|--------|---------------------|
|
|
||||||
| long-text | own-node URI display | ✅ covered | The displayed `pubkey@host:port` string truncates (CSS `truncate` + `title` tooltip, the existing house pattern) to fit its container; the full untruncated value is what gets copied to clipboard regardless of visual truncation |
|
|
||||||
| empty | trusted-nodes picker list | ✅ covered | Empty state copy row above renders once when both the trusted and meshed-peer lists are empty (shared empty state, not duplicated per column) |
|
|
||||||
| empty | meshed-LN-peers picker list | 🧪 backstop | Same shared empty-state copy as above; no wired test yet asserting the "shared, not duplicated" rendering rule — flag for planner/executor to add a component test |
|
|
||||||
| loading | trusted-nodes picker list | ✅ covered | Mirrors `NodeList.vue`'s existing "Loading nodes..." spinner row treatment |
|
|
||||||
| loading | meshed-LN-peers picker list | ✅ covered | Same spinner treatment as trusted-nodes list |
|
|
||||||
| error | trusted-nodes / meshed-peer picker lists | ✅ covered | Ties to the Copywriting Contract error row; styled with `.alert-error`/`openError` convention already in `LightningChannelsPanel.vue` |
|
|
||||||
| populated | trusted-nodes picker list | ✅ covered | Row layout mirrors `NodeList.vue`'s trusted-node row: name, trust badge, transport badge (FIPS/Tor), one-click "Open Channel" button |
|
|
||||||
| populated | meshed-LN-peers picker list | ✅ covered | Same row layout, "Request Channel" button in place of "Open Channel" (peers are not bilaterally trusted, so the action is a request, never a direct open) |
|
|
||||||
| zero-one-many | trusted-nodes / meshed-peer lists | ✅ covered (dismissed) | No item-count copy is planned for either list (unlike e.g. the channel-status tabs' count badges) — singular/plural phrasing is not applicable |
|
|
||||||
| overflow | picker list rows (long node names) | ✅ covered | `truncate` class + `:title` tooltip on the node-name span, identical to the existing `NodeList.vue` convention |
|
|
||||||
| partial | manual-URI-paste form | ✅ covered | A pasted pubkey without a host falls back to `lnd.openchannel`'s existing address-less-pubkey handling (`address = parts[1] \|\| undefined`), already proven in `LightningChannelsPanel.vue` |
|
|
||||||
| error | manual-URI-paste form | 🧪 backstop | Invalid-format message ("Peer URI must be `pubkey@host:port`") is specified but no explicit format-validation test is scoped yet — planner should add one, do not silently skip client-side validation before calling `lnd.openchannel` |
|
|
||||||
| long-text | manual-URI-paste form | ✅ covered | Same truncation/tooltip treatment as the own-node URI display |
|
|
||||||
| unclassified | request-to-open-channel flow | ✅ covered (dismissed) | Reuses `PeerRequestModal.vue` verbatim (message field, Send Request/Sending states) — its own state coverage predates this phase and is not re-specified here |
|
|
||||||
| long-text | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered (dismissed) | The ring itself renders no text content (pure SVG/CSS segments); the 48px sats amount inside it is inherited text explicitly out of this contract (see Typography inherited note) |
|
|
||||||
| overflow | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered | New `badge` `ScreensaverRing` size variant (see below) is explicitly sized to fit inside the modal's `max-h-[90vh] overflow-y-auto` card without clipping — do not drop in the existing `compact` (240–320px) variant unscaled |
|
|
||||||
| static-content (motion) | paid-tick ring, all `ScreensaverRing` size variants | 🧪 backstop | `ScreensaverRing.vue`'s `segment-pulse` animation currently has **no** `prefers-reduced-motion` guard anywhere (a real gap — confirmed by reading the component; contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`, which already has one). This phase must add `@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }` inside `ScreensaverRing.vue` itself so the guard applies to every size variant (including the new `badge` one), matching the site-wide reduced-motion convention. No existing automated test covers this — flag for planner as a Wave 0 test gap. |
|
|
||||||
|
|
||||||
<!-- Status vocabulary (locked by probe-core projectTruths):
|
|
||||||
✅ covered → a plain truth string lifted into must_haves.truths
|
|
||||||
🧪 backstop → a flat scalar { statement, verification: backstop }; at verify time, no explicit
|
|
||||||
evidence → insufficient_spec → human_needed (never a silent pass, #1154)
|
|
||||||
⚠ unresolved → an explicit planner assumption (surfaced, never silently dropped)
|
|
||||||
Rows are REPLACED (not appended) on a probe re-run — idempotent. -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FED-05 Visual Anchor
|
|
||||||
|
|
||||||
Primary visual anchor: the trusted-nodes list (federation trust is the primary path); meshed-lightning-peers list second; manual-paste fallback visually de-emphasized below both (collapsed behind the "Paste URI Manually" entry point per the Copywriting Contract above, not rendered as a third equal-weight column).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FED-06 Sizing Decision (resolves RESEARCH.md Pitfall 4 / Assumption A4)
|
|
||||||
|
|
||||||
RESEARCH.md flagged the `ScreensaverRing` size mismatch (`compact` = 240–320px vs. the current 96–112px paid-tick badges) as needing a UI-spec decision before implementation. **Decision: add a new `badge` size variant to `ScreensaverRing.vue`**, not a CSS `transform: scale()` wrapper — cleaner, reusable across both call sites, and avoids reduced-motion/layout-box mismatches that a transform hack would introduce.
|
|
||||||
|
|
||||||
| Variant | Diameter (mobile) | Diameter (≥768px) | `--viz-radius` | Used by |
|
|
||||||
|---------|-------------------|--------------------|-----------------|---------|
|
|
||||||
| `badge` (NEW) | 160px | 192px | 80px / 96px | `SendBitcoinModal.vue` `.send-success-burst` (replaces the 112px burst), `WalletScanModal.vue` `.success-ring` (replaces the 96px/`w-24` ring) |
|
|
||||||
| `compact` (existing, unchanged) | 240px | 320px | 120px / 160px | `SystemDangerZone.vue` and other existing overlay contexts — do not touch |
|
|
||||||
| `default` (existing, unchanged) | 280–400px (responsive) | — | 140–200px | Full-screen `Screensaver.vue` |
|
|
||||||
|
|
||||||
Composition at both call sites: `<ScreensaverRing size="badge" />` renders the radiating EQ segments; the existing `.burst-core` (green circle + checkmark, `SendBitcoinModal.vue`) or `.success-ring` inner content (`WalletScanModal.vue`) is layered centered on top via `position: absolute; inset: 0` within a shared `position: relative` wrapper sized to the `badge` diameter — same layering pattern `Screensaver.vue` already uses for `ScreensaverLogo` inside `ScreensaverRing`. Do not resize or restyle the checkmark/core itself; only its container changes from a bespoke 96–112px circle to the `badge`-sized wrapper.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Registry Safety
|
|
||||||
|
|
||||||
| Registry | Blocks Used | Safety Gate |
|
|
||||||
|----------|-------------|--------------|
|
|
||||||
| shadcn official | none | not applicable — shadcn/ui is React-only; this is a Vue 3 project with an established hand-rolled design system (see Design System table) |
|
|
||||||
| third-party | none | not applicable |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checker Sign-Off
|
|
||||||
|
|
||||||
- [ ] Dimension 1 Copywriting: PASS
|
|
||||||
- [ ] Dimension 2 Visuals: PASS
|
|
||||||
- [ ] Dimension 3 Color: PASS
|
|
||||||
- [ ] Dimension 4 Typography: PASS
|
|
||||||
- [ ] Dimension 5 Spacing: PASS
|
|
||||||
- [ ] Dimension 6 Registry Safety: PASS
|
|
||||||
|
|
||||||
**Approval:** pending
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 1
|
|
||||||
slug: federation-mesh-hardening
|
|
||||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
|
||||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
|
||||||
status: draft
|
|
||||||
nyquist_compliant: false
|
|
||||||
wave_0_complete: false
|
|
||||||
created: 2026-07-29
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 1 — Validation Strategy
|
|
||||||
|
|
||||||
> Per-phase validation contract for feedback sampling during execution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Infrastructure
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| **Framework** | cargo test (Rust, workspace at core/) + bash harnesses (tests/multinode/, tests/lifecycle/) + node --check / manual curl for mock-backend |
|
|
||||||
| **Config file** | core/Cargo.toml (workspace); tests/multinode/smoke.sh |
|
|
||||||
| **Quick run command** | `cd core && cargo test -p archipelago federation` |
|
|
||||||
| **Full suite command** | `cd core && cargo test` (plus on-node `tests/multinode/smoke.sh` for cross-node behavior) |
|
|
||||||
| **Estimated runtime** | ~120 seconds (cargo test); multinode smoke is node-gated |
|
|
||||||
|
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sampling Rate
|
|
||||||
|
|
||||||
- **After every task commit:** Run `cd core && cargo test -p archipelago federation` (or the targeted module's tests)
|
|
||||||
- **After every plan wave:** Run `cd core && cargo test`; frontend waves: `cd neode-ui && npm run build` + grep dist for new strings
|
|
||||||
- **Before `/gsd-verify-work`:** Full suite green + multinode smoke considerations noted (cross-node checks are hardware/node-gated)
|
|
||||||
- **Max feedback latency:** 180 seconds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Per-Task Verification Map
|
|
||||||
|
|
||||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
|
||||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
|
||||||
| (filled by planner) | — | — | FED-01..06 | — | — | — | — | — | ⬜ pending |
|
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wave 0 Requirements
|
|
||||||
|
|
||||||
- [ ] Storage-race unit tests for `federation/storage.rs` (concurrent load/save + remove-during-sync) — stubs for FED-01/FED-02
|
|
||||||
- [ ] Mock-backend RPC parity checks (mesh contacts + message-mutation methods) — FED-04 remainder
|
|
||||||
|
|
||||||
*Existing infrastructure covers cargo test; multinode smoke.sh covers cross-node sync but runs on-node only.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual-Only Verifications
|
|
||||||
|
|
||||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
|
||||||
|----------|-------------|------------|-------------------|
|
|
||||||
| Removed peer never reappears across real fleet sync cycles | FED-01 | Needs two live nodes + wall-clock sync cycles | Remove a peer on archi-dev, watch peer list through ≥2 sync cycles (90s loop), confirm absent + error surfaced on induced failure |
|
|
||||||
| Channel-open UX end-to-end | FED-05 | Visual/UX judgment + live LND | Drive :8100 preview against archi-dev; share URI, open channel to trusted node, request public-node channel |
|
|
||||||
| Paid-tick animation on-brand | FED-06 | Visual judgment | Trigger payment success in preview; compare ring/EQ segments to screensaver |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sign-Off
|
|
||||||
|
|
||||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
|
||||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
|
||||||
- [ ] Wave 0 covers all MISSING references
|
|
||||||
- [ ] No watch-mode flags
|
|
||||||
- [ ] Feedback latency < 180s
|
|
||||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
|
||||||
|
|
||||||
**Approval:** pending
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
phase: quick-260729-fw7
|
|
||||||
plan: 01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- neode-ui/src/views/mesh/HopVizModal.vue
|
|
||||||
- neode-ui/src/views/Mesh.vue
|
|
||||||
- neode-ui/src/views/mesh/mesh-styles.css
|
|
||||||
autonomous: true
|
|
||||||
requirements: [QUICK-FW7]
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Clicking a mesh message's transport pill (or the ⋯ button) opens a hop-route modal that is visually balanced on desktop — the hop chain fills the modal width, endpoint nodes are prominent, and the modal is no longer a cramped 420px card with tiny emoji + dot row"
|
|
||||||
- "On narrow viewports (phone width) the hop chain renders VERTICALLY — sender at top, relays stacked, recipient at bottom — with the packet animation traveling top-to-bottom, nothing overflowing or wrapping awkwardly"
|
|
||||||
- "The visualization uses Archipelago's design language: glass panel, dark-only palette, per-transport accent colors (meshcore orange #fb923c, meshtastic mint #3eb489, reticulum blue #60a5fa, lora amber #f59e0b, fips violet #a78bfa, tor indigo #818cf8), EQ-segment-style bars echoing ScreensaverRing, node glow, staggered reveal, animated packet traveling the path"
|
|
||||||
- "prefers-reduced-motion disables all looping animations (existing behavior preserved)"
|
|
||||||
- "Tor / FIPS / unknown-transport cases still render their distinct shapes (3 anonymous relays / direct P2P / not recorded), and SNR/RSSI + E2E/delivery metadata still display"
|
|
||||||
artifacts:
|
|
||||||
- "neode-ui/src/views/mesh/HopVizModal.vue — new self-contained modal component (Teleport to body) with scoped styles"
|
|
||||||
- "neode-ui/src/views/Mesh.vue — inline hop-viz modal markup replaced by <HopVizModal>"
|
|
||||||
- "web/dist/neode-ui/ — rebuilt bundle containing the new component's class strings"
|
|
||||||
key_links:
|
|
||||||
- "Mesh.vue transport pill / ⋯ button click → hopVizMsg → <HopVizModal :msg :peer @close> renders"
|
|
||||||
- "HopVizModal derives accent color from msg.transport, matching the pill colors in mesh-styles.css lines 168-173"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Redesign the mesh-message hop visualization modal in neode-ui: properly sized and balanced on desktop, vertical stacked layout on mobile, and fully on-brand (glass, per-transport accents, EQ-segment motif from ScreensaverRing, animated packet travel, node glow, staggered reveal).
|
|
||||||
|
|
||||||
User feedback (verbatim): "please make the hop graphic and animation on mesh messages much better balanced, the desktop one is very small and it doesn't work on mobile where it should be vertical, and make it much more archipelago style and branded, make it beautiful."
|
|
||||||
|
|
||||||
Purpose: The current hop viz (inline in Mesh.vue lines ~2627-2678, styles in mesh-styles.css lines ~597-637) is a cramped horizontal flex row inside a 420px modal — tiny emoji endpoints, a dashed border with blinking `•` dots, no mobile handling. It reads as an afterthought, not an Archipelago feature.
|
|
||||||
|
|
||||||
Output: New `HopVizModal.vue` component wired into Mesh.vue, old inline markup and `.mesh-hopviz-*` CSS removed, rebuilt bundle in web/dist/neode-ui/ verified to contain the new strings.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
|
|
||||||
**Current implementation (read all of these first):**
|
|
||||||
- `neode-ui/src/views/Mesh.vue` lines ~1291-1310 (hopVizMsg / hopVizPeer / hopVizHops state), lines ~2340-2350 (transport pill + ⋯ button that set `hopVizMsg`), lines ~2627-2678 (the inline Teleported modal to replace). Also `transportLabel()` and `signalQualityLabel()` helpers used by the modal — grep for them in Mesh.vue.
|
|
||||||
- `neode-ui/src/views/mesh/mesh-styles.css` lines ~160-175 (transport pill accent colors + `.mesh-chat-e2e`), ~572-584 (`.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, title/sub/cancel — SHARED with the send-transport and image-quality modals, do not break them), ~597-637 (`.mesh-hopviz-*` rules to delete/migrate).
|
|
||||||
- `neode-ui/src/components/ScreensaverRing.vue` — the brand EQ-segment motif: thin 4px rounded bars, white gradient fill, staggered `scaleY` pulse keyframes. Echo this visual language for relay-node markers.
|
|
||||||
- `neode-ui/tailwind.config.js` — glass tokens (glass-dark/glass-border/shadow-glass), fonts: `Montserrat` = `font-archipelago` header font; app is dark-only.
|
|
||||||
- Global focus glow + accent used app-wide: orange `rgba(251,146,60,…)` (#fb923c).
|
|
||||||
|
|
||||||
**Transport accent map (must match pill colors):** meshtastic `#3eb489`, meshcore `#fb923c`, reticulum `#60a5fa`, lora `#f59e0b`, fips `#a78bfa`, tor `#818cf8`.
|
|
||||||
|
|
||||||
**Data realities:** For LoRa transports only a hop COUNT is known (`peer.hops`, 0 or 0xff/null = direct), not per-relay identities — render count relays as anonymous branded markers. Tor = fixed "3 anonymous relays" shape. FIPS = direct P2P. `null` transport = not recorded. SNR/RSSI are current link readings (keep the existing disclaimer note).
|
|
||||||
|
|
||||||
**Project rules that apply:**
|
|
||||||
- Modals MUST `<Teleport to="body">` with full-screen backdrop (already true — preserve it).
|
|
||||||
- Frontend build can silently no-op: after `npm run build`, grep the built bundle in `web/dist/neode-ui/` for new strings before claiming done.
|
|
||||||
- Commit the code changes when they work; push via `git push gitea-ai main`. Stage explicitly by path (`git add <paths>`), never `git add -A` — other agents may share the tree.
|
|
||||||
- Do not deploy to any node; this rides the normal dev-pair → OTA pipeline later.
|
|
||||||
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 1: Build HopVizModal.vue — branded, balanced desktop hop visualization</name>
|
|
||||||
<files>neode-ui/src/views/mesh/HopVizModal.vue, neode-ui/src/views/Mesh.vue, neode-ui/src/views/mesh/mesh-styles.css</files>
|
|
||||||
<action>
|
|
||||||
Create `neode-ui/src/views/mesh/HopVizModal.vue` (script setup, TypeScript) and move the hop-viz modal out of Mesh.vue into it.
|
|
||||||
|
|
||||||
Component contract:
|
|
||||||
- Props: `msg: MeshMessage` (import type from `../../types/api` or wherever Mesh.vue imports it), `peer: MeshPeer | null`, plus the two label strings Mesh.vue already computes (`transportLabel` result and `signalQualityLabel` result) OR import/reuse those helpers if they are importable; if they are local functions in Mesh.vue, pass computed strings as props — do NOT duplicate logic.
|
|
||||||
- Emits: `close`.
|
|
||||||
- Template: `<Teleport to="body">` wrapping a full-screen backdrop (reuse `.mesh-transport-modal-backdrop` class so backdrop behavior stays consistent) with `@click.self="emit('close')"`, containing a `glass-card` panel.
|
|
||||||
|
|
||||||
Visual redesign (all styles SCOPED in the component; delete the old `.mesh-hopviz-*` rules from mesh-styles.css lines ~597-637 — but leave `.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, `.mesh-transport-title/sub/cancel` untouched since the send-transport and image-quality modals still use them; the new panel should use its own width class, wider than 420px — target `min(560px, 94vw)` on desktop):
|
|
||||||
|
|
||||||
1. **Header:** transport-colored title using the Montserrat brand font (`font-family: 'Montserrat', sans-serif` or the `font-archipelago` utility), e.g. "MeshCore route", with the existing You → peer subtitle. Derive an `--hop-accent` CSS custom property on the panel root from `msg.transport` using the exact pill color map (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback rgba(251,146,60) orange). All accents below use `var(--hop-accent)`.
|
|
||||||
|
|
||||||
2. **Endpoint nodes (You / peer):** substantial circular medallions (~64-72px) instead of bare emoji — island glyph 🏝️ centered inside a ring of 12-16 EQ-style segments (thin rounded bars radiating like a compact ScreensaverRing — reuse its technique: absolutely-positioned bars, `transform: rotate(deg) translateY(-radius)`, staggered `scaleY` pulse animation, white-to-transparent gradient tinted with the accent). Soft accent glow behind each medallion (`box-shadow: 0 0 24px color-mix(...)` or an rgba shadow). Node name below in white 600-weight, ellipsized.
|
|
||||||
|
|
||||||
3. **Path between endpoints:** replace the dashed-border + blinking `•` row with a proper track: a horizontal line/gradient in the accent color connecting the medallions, with:
|
|
||||||
- Relay markers for each hop (LoRa transports: `Math.min(hops, 6)` markers; Tor: exactly 3 with a 🧅/anonymous treatment; FIPS: no relays, a single direct link) rendered as small EQ-segment clusters or glowing accent dots (~10-14px) sitting ON the track, each with its own subtle pulse, staggered.
|
|
||||||
- An animated packet: a small bright dot/comet (accent color, blurred glow trail) traveling from sender to recipient along the track on an infinite ~2s loop. Use a CSS keyframe translating along the track container (like the existing `mesh-hopviz-travel` sweep but as a discrete glowing packet, not a background sheen).
|
|
||||||
- Label under/over the track: "direct radio link" / "N hops" / "3 anonymous relays" / "direct peer-to-peer" / "transport wasn't recorded" — preserve the existing per-transport template branches and copy.
|
|
||||||
- Staggered entrance: sender medallion, then track+relays, then recipient fade/slide in (keep the existing appear pattern, ~0.05/0.35/0.65s delays).
|
|
||||||
|
|
||||||
4. **Metadata footer:** keep the SNR/RSSI signal row (LoRa only, with the existing "current link readings" disclaimer note) and the E2E / delivered ✓✓ / timestamp row, restyled as small glass chips consistent with `.mesh-transport-meta` sizing. Keep the Close button (`.mesh-transport-cancel` class is fine).
|
|
||||||
|
|
||||||
5. **Reduced motion:** wrap ALL looping animations (packet, segment pulses, relay pulses) and entrance animations in the component's own `@media (prefers-reduced-motion: reduce)` block that disables them — the old CSS did this; the new component must too.
|
|
||||||
|
|
||||||
Wire-up in Mesh.vue: import HopVizModal, replace the inline `<Teleport>` block at lines ~2627-2678 with `<HopVizModal v-if="hopVizMsg" :msg="hopVizMsg" :peer="hopVizPeer" ... @close="hopVizMsg = null" />`. Keep `hopVizMsg`/`hopVizPeer`/`hopVizHops` state in Mesh.vue (or move `hopVizHops` into the component — it only needs `peer.hops`; prefer moving it in to shrink Mesh.vue). Do not touch the transport pill / ⋯ button triggers.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vue-tsc --noEmit 2>/dev/null || npm run build</automated>
|
|
||||||
</verify>
|
|
||||||
<done>HopVizModal.vue exists with the medallion + track + packet design, Mesh.vue renders it in place of the inline modal, old `.mesh-hopviz-*` rules removed from mesh-styles.css, other transport modals' shared classes untouched, type-check/build passes.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Mobile vertical layout</name>
|
|
||||||
<files>neode-ui/src/views/mesh/HopVizModal.vue</files>
|
|
||||||
<action>
|
|
||||||
Add responsive behavior inside HopVizModal.vue's scoped styles. At narrow widths (`@media (max-width: 560px)` — pick the breakpoint so a typical phone portrait always gets it):
|
|
||||||
- The chain flips to a COLUMN: sender medallion at top, vertical track with relay markers stacked below it, recipient medallion at bottom. Implement so the same DOM works in both orientations (flex-direction column + a track that switches from horizontal line to vertical line), rather than duplicating markup.
|
|
||||||
- The packet animation travels TOP-TO-BOTTOM along the vertical track (a second keyframe or a transform-based animation that follows the flex axis).
|
|
||||||
- Medallions may shrink slightly (~56px) but stay prominent; names and hop label must not truncate mid-word or overflow the panel; panel uses near-full width (`width: 94vw`) with comfortable vertical padding, and the whole modal scrolls (`max-height: 90vh; overflow-y: auto`) if metadata pushes it tall.
|
|
||||||
- Entrance stagger and reduced-motion handling apply identically in vertical mode.
|
|
||||||
Sanity-check both orientations in the browser via the dev preview (`npm run dev`, viewport toggling in devtools) if a display is available; otherwise rely on the CSS being purely breakpoint-driven and symmetric.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && grep -q "max-width: 560px" src/views/mesh/HopVizModal.vue && grep -qi "column" src/views/mesh/HopVizModal.vue</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Below the breakpoint the hop chain renders vertically (sender top → recipient bottom) with the packet traveling downward; no overflow; desktop layout unchanged above the breakpoint.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: Build, verify bundle, commit</name>
|
|
||||||
<files>web/dist/neode-ui/ (build output), neode-ui/src/views/mesh/HopVizModal.vue</files>
|
|
||||||
<action>
|
|
||||||
1. `cd neode-ui && npm run build` (outputs to `web/dist/neode-ui/`).
|
|
||||||
2. Per CLAUDE.md, prove the build actually picked up the change: grep the built JS/CSS bundle for a new unique string from the component (e.g. a distinctive class name like `hopviz-medallion` or `hopviz-packet` — whatever class names Task 1 used; pick one that did not exist before): `grep -rl "hopviz-packet" web/dist/neode-ui/assets/` (adjust the token to the actual class name). It MUST match; if it doesn't, the build silently no-opped — investigate before proceeding.
|
|
||||||
3. Also confirm the OLD inline markup is gone from the bundle source of truth: `grep -c "mesh-hopviz-chain" neode-ui/src/views/Mesh.vue` returns 0.
|
|
||||||
4. Commit the code changes only (docs/planning files are committed by the orchestrator): `git add neode-ui/src/views/mesh/HopVizModal.vue neode-ui/src/views/Mesh.vue neode-ui/src/views/mesh/mesh-styles.css web/dist/neode-ui` — stage exactly these paths, never `git add -A`. Check `git status` first for other agents' unrelated changes and leave them alone. Commit message: `feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile` ending with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer. Push: `git push gitea-ai main`.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>grep -rl "hopviz" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/ | head -1 && cd /home/archipelago/Projects/archy && git log --oneline -1 | grep -qi "hop"</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Fresh build in web/dist/neode-ui/ contains the new component's class strings, Mesh.vue no longer contains the old inline hopviz markup, and the change is committed and pushed via gitea-ai.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- `npm run build` succeeds in neode-ui/; bundle in web/dist/neode-ui/ contains a new hopviz class string (silent-no-op guard).
|
|
||||||
- Old `.mesh-hopviz-*` rules removed; `.mesh-transport-modal-backdrop` / `.mesh-transport-option` / image-quality modal styles untouched (grep mesh-styles.css and open the send-transport modal path in code to confirm shared classes intact).
|
|
||||||
- Modal still Teleports to body with full-screen backdrop.
|
|
||||||
- All five transport branches render: meshcore/meshtastic/reticulum (hop count), tor (3 relays), fips (direct), null (not recorded).
|
|
||||||
- prefers-reduced-motion block present in the new component.
|
|
||||||
<human-check>On the dev preview (:8100 or `npm run dev`), open Mesh chat, click a message's transport pill: desktop shows the wide balanced medallion+packet layout; shrinking the window below the breakpoint flips it vertical. Confirm it "feels Archipelago" — glass, accent glow, EQ-segment motif.</human-check>
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Desktop hop modal is visually balanced: ~560px panel, prominent glowing endpoint medallions with EQ-segment rings, accent-colored track with animated traveling packet and staggered relay markers.
|
|
||||||
- Mobile (< 560px) renders the chain vertically top-to-bottom with the packet traveling downward; nothing overflows.
|
|
||||||
- Per-transport accent colors match the existing transport pill colors exactly.
|
|
||||||
- Reduced-motion users get a static layout.
|
|
||||||
- Built bundle verified to contain the new strings; code committed and pushed via gitea-ai.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Executor commits code only. On completion, note results for the orchestrator; no SUMMARY.md required for quick mode unless the orchestrator asks.
|
|
||||||
</output>
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
---
|
|
||||||
phase: quick-260729-fw7
|
|
||||||
plan: 01
|
|
||||||
subsystem: neode-ui/mesh
|
|
||||||
tags: [frontend, mesh, hop-viz, branding, animation, responsive]
|
|
||||||
requires: []
|
|
||||||
provides:
|
|
||||||
- HopVizModal.vue branded hop-route visualization component
|
|
||||||
affects:
|
|
||||||
- neode-ui mesh chat (transport pill / ⋯ route modal)
|
|
||||||
tech-stack:
|
|
||||||
added: []
|
|
||||||
patterns:
|
|
||||||
- Self-contained Teleport-to-body modal component with scoped styles
|
|
||||||
- EQ-segment ring motif (ScreensaverRing technique) reused for endpoint medallions
|
|
||||||
- CSS custom property accent theming (--hop-accent) derived per transport in JS
|
|
||||||
key-files:
|
|
||||||
created:
|
|
||||||
- neode-ui/src/views/mesh/HopVizModal.vue
|
|
||||||
modified:
|
|
||||||
- neode-ui/src/views/Mesh.vue
|
|
||||||
- neode-ui/src/views/mesh/mesh-styles.css
|
|
||||||
decisions:
|
|
||||||
- "Label strings (transportLabel/signalQualityLabel/timeAgo) passed as props from Mesh.vue — no logic duplication"
|
|
||||||
- "hopVizHops moved into the component (derived from peer.hops); hopVizMsg/hopVizPeer state stays in Mesh.vue"
|
|
||||||
- "web/dist/neode-ui NOT committed — web/ is gitignored (.gitignore:74); build output is intentionally untracked in this repo"
|
|
||||||
metrics:
|
|
||||||
duration: ~15m
|
|
||||||
completed: 2026-07-29
|
|
||||||
status: complete
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quick Task 260729-fw7: Mesh Hop-Route Visualization Redesign Summary
|
|
||||||
|
|
||||||
**One-liner:** Replaced the cramped 420px inline hop-viz modal with a self-contained branded HopVizModal.vue — 560px balanced desktop layout with EQ-segment-ringed glowing medallions, per-transport accent track with animated traveling packet and staggered relay markers, flipping to a vertical sender-top→recipient-bottom chain below 560px.
|
|
||||||
|
|
||||||
## What Was Built
|
|
||||||
|
|
||||||
- **`neode-ui/src/views/mesh/HopVizModal.vue`** (new, ~430 lines): Teleport-to-body modal, `min(560px, 94vw)` glass panel, `max-height: 90vh` scrollable.
|
|
||||||
- Montserrat transport-colored title; `--hop-accent` / `--hop-accent-soft` / `--hop-accent-faint` CSS vars derived from `msg.transport` matching the chat pill colors exactly (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback orange).
|
|
||||||
- Endpoint medallions: 72px (56px mobile), island glyph on an accent-tinted disc with glow, ringed by 14 EQ segments using the ScreensaverRing rotate+translateY+scaleY-pulse technique.
|
|
||||||
- Track: accent gradient line, relay markers positioned fractionally along it — mini 3-bar EQ clusters for radio hops (`min(hops, 6)`), 🧅 ×3 for Tor, none for FIPS/unknown (unknown dims the line). Animated white/accent glowing packet travels sender→recipient on a 2.2s loop.
|
|
||||||
- Per-transport labels preserved verbatim: "direct radio link" / "N hops" / "3 anonymous relays" / "FIPS overlay · direct peer-to-peer" / "transport wasn't recorded".
|
|
||||||
- Staggered entrance (0.05/0.35/0.65s), metadata as glass chips (signal + SNR/RSSI + disclaimer note; E2E badge + delivered ✓✓ + time).
|
|
||||||
- `@media (max-width: 560px)`: chain flips to column, track becomes a vertical line, packet animates top→bottom (`hopviz-packet-y`), same DOM.
|
|
||||||
- `@media (prefers-reduced-motion: reduce)`: all loops and entrance animations disabled, packet hidden.
|
|
||||||
- **`Mesh.vue`**: inline 50-line Teleport block replaced by `<HopVizModal>`; label helpers passed as computed props; `hopVizHops()` removed (moved into component).
|
|
||||||
- **`mesh-styles.css`**: all `.mesh-hopviz-*` rules and their keyframes/reduced-motion block deleted; `.mesh-chat-transport-clickable`, `.mesh-transport-modal-*` (shared with send-transport + image-quality modals), and `.mesh-chat-more-btn` untouched (12 shared-class occurrences verified intact).
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
| Task | Commit | Description |
|
|
||||||
| ---- | ------ | ----------- |
|
|
||||||
| 1–3 | `ac09fc5d` | feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `npx vue-tsc --noEmit` → exit 0.
|
|
||||||
- `npm run build` → success; bundle guard (CLAUDE.md silent-no-op rule): `hopviz-packet` and `hopviz-medallion` found in `web/dist/neode-ui/assets/Mesh-D2ImmPoh.js` + `Mesh-Dy7zKwro.css`; old `mesh-hopviz-chain` string absent from both source and bundle.
|
|
||||||
- Task 2 grep checks: `max-width: 560px` + `column` present in HopVizModal.vue.
|
|
||||||
- Submodule guard run before commit (no indeedhub paths staged); no file deletions in the commit.
|
|
||||||
|
|
||||||
## Deviations from Plan
|
|
||||||
|
|
||||||
**1. [Rule 3 - Blocking] `web/dist/neode-ui` not staged/committed**
|
|
||||||
- **Found during:** Task 3
|
|
||||||
- **Issue:** Plan said to stage `web/dist/neode-ui`, but `web/` is gitignored (`.gitignore:74`) and untracked — build output is intentionally excluded from the repo (release tarballs are built from it at ship time).
|
|
||||||
- **Fix:** Committed the three source files only; fresh build exists on disk in `web/dist/neode-ui/` and was grep-verified.
|
|
||||||
|
|
||||||
**2. Push deferred to orchestrator** — plan Task 3 said `git push gitea-ai main`, but the executor constraints state the orchestrator handles pushing; not pushed here.
|
|
||||||
|
|
||||||
## Known Stubs
|
|
||||||
|
|
||||||
None — no placeholder text, hardcoded-empty data paths, or unwired components introduced.
|
|
||||||
|
|
||||||
## Human Verification Pending
|
|
||||||
|
|
||||||
On the dev preview (`npm run dev` or :8100), open a mesh chat and click a message's transport pill: desktop shows the wide medallion+packet layout; shrinking below 560px flips it vertical. Confirm brand feel (glass, accent glow, EQ motif).
|
|
||||||
|
|
||||||
## Self-Check: PASSED
|
|
||||||
|
|
||||||
- FOUND: neode-ui/src/views/mesh/HopVizModal.vue
|
|
||||||
- FOUND: commit ac09fc5d on main
|
|
||||||
- FOUND: hopviz strings in web/dist/neode-ui/assets/ (fresh build)
|
|
||||||
- CONFIRMED: 0 occurrences of `mesh-hopviz` in Mesh.vue and mesh-styles.css
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
---
|
|
||||||
phase: quick-260729-gjd
|
|
||||||
plan: 01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- neode-ui/docker/nginx-demo.conf
|
|
||||||
- neode-ui/docker/indee-demo-signin.js
|
|
||||||
- neode-ui/Dockerfile.web
|
|
||||||
- docker-compose.demo.yml
|
|
||||||
- demo-deploy/docker-compose.yml
|
|
||||||
- neode-ui/src/composables/useDemoIntro.ts
|
|
||||||
- neode-ui/src/views/appSession/useAppIdentity.ts
|
|
||||||
- neode-ui/mock-backend.js
|
|
||||||
autonomous: true
|
|
||||||
requirements: [QUICK-260729-GJD]
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
|
|
||||||
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
|
|
||||||
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
|
|
||||||
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
|
|
||||||
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
|
|
||||||
artifacts:
|
|
||||||
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
|
|
||||||
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
|
|
||||||
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
|
|
||||||
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
|
|
||||||
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
|
|
||||||
key_links:
|
|
||||||
- "demoAppUrl('indeedhub') → http://<demo-host>:2101/ → nginx :2101 server block → https://indee.tx1138.com upstream"
|
|
||||||
- "nginx sub_filter → /__demo/indee-demo-signin.js → seeds indeedhub-accounts + indeedhub-active-account localStorage keys → IndeeHub boot-restore logs the visitor in"
|
|
||||||
- "staticDevApps['indeedhub'] → structuredClone into per-session package-data → My Apps grid on fresh session"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Make IndeeHub a first-class app in the PUBLIC DEMO only: (1) the real site
|
|
||||||
https://indee.tx1138.com/ renders inside the in-app iframe session (today it is
|
|
||||||
frame-busted by `X-Frame-Options: SAMEORIGIN` and opens externally), (2) a demo
|
|
||||||
visitor sees IndeeHub already signed in with a throwaway demo Nostr identity
|
|
||||||
(no login wall, no identity-picker modal), and (3) IndeeHub appears as an
|
|
||||||
already-installed, running app on a completely fresh demo session.
|
|
||||||
|
|
||||||
Purpose: the demo currently punts IndeeHub to a new tab with a login wall —
|
|
||||||
the flagship media app looks broken/hostile to demo visitors.
|
|
||||||
Output: demo-scoped changes across nginx-demo.conf, a new sign-in seed script,
|
|
||||||
the two demo compose files, useDemoIntro.ts, useAppIdentity.ts, mock-backend.js.
|
|
||||||
|
|
||||||
All behavior changes are gated behind IS_DEMO / demo-image build paths. The
|
|
||||||
real-node build must be completely unaffected.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
||||||
@$HOME/.claude/gsd-core/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@CLAUDE.md
|
|
||||||
@neode-ui/src/composables/useDemoIntro.ts
|
|
||||||
@neode-ui/docker/nginx-demo.conf
|
|
||||||
@neode-ui/Dockerfile.web
|
|
||||||
@neode-ui/Dockerfile.backend
|
|
||||||
@docker-compose.demo.yml
|
|
||||||
@demo-deploy/docker-compose.yml
|
|
||||||
@neode-ui/src/views/appSession/appSessionConfig.ts
|
|
||||||
@neode-ui/src/views/appSession/useAppIdentity.ts
|
|
||||||
@neode-ui/src/views/appSession/useNostrBridge.ts
|
|
||||||
@neode-ui/src/stores/appLauncher.ts
|
|
||||||
@neode-ui/mock-backend.js
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<verified_findings>
|
|
||||||
Facts confirmed by inspection on 2026-07-29 (do not re-derive, but re-verify
|
|
||||||
the live-bundle details marked "verify at exec time"):
|
|
||||||
|
|
||||||
- `curl -sI https://indee.tx1138.com/` → `X-Frame-Options: SAMEORIGIN`, no
|
|
||||||
Content-Security-Policy header today (strip both defensively).
|
|
||||||
- The live index.html loads a hashed module bundle from absolute-root paths
|
|
||||||
(`/assets/index-*.js`, `/icons/...`, `/manifest.json`). This is why the old
|
|
||||||
`/app/indeedhub/` path-prefix + sub_filter proxy broke (asset/router paths
|
|
||||||
escape the prefix). A WHOLE-ORIGIN proxy on a dedicated port has no such
|
|
||||||
problem — the SPA sees itself at `/` and every relative call just works.
|
|
||||||
- The live bundle (assets/index-BMWtjRCn.js) uses applesauce-accounts:
|
|
||||||
- localStorage key `"indeedhub-accounts"` = JSON array of serialized
|
|
||||||
accounts (`Fe.toJSON`/`Fe.fromJSON`), restored on boot before UI renders.
|
|
||||||
- localStorage key `"indeedhub-active-account"` = active account id.
|
|
||||||
- A private-key account class exists whose `fromJSON` does
|
|
||||||
`const t=Vn(e.signer.key); new mr(e.pubkey, new ai(t))` — i.e. shape is
|
|
||||||
`{ id, type: "<verify at exec time>", pubkey, signer: { key: "<hex sk>" } }`
|
|
||||||
plus common fields from `loadCommonFields` (verify exact `type` string and
|
|
||||||
common fields by grepping the live bundle for `static type` / the class's
|
|
||||||
`toJSON`). An `"extension"` account type also exists (fallback path).
|
|
||||||
- The bundle expects NIP-07 as a real `window.nostr` object ("Signer
|
|
||||||
extension missing" guard) — it does NOT contain the archipelago
|
|
||||||
`nostr-request` postMessage client.
|
|
||||||
- Parent-side NIP-07 plumbing already exists: `AppSession.vue` line ~423
|
|
||||||
routes `nostr-request` messages to `useNostrBridge`, which calls
|
|
||||||
`node.nostr-pubkey` (mocked in mock-backend.js) and `node.nostr-sign`
|
|
||||||
(NOT implemented in mock-backend.js). Only needed for the fallback approach.
|
|
||||||
- `useAppIdentity.ts`: `isIdentityAwareApp('indeedhub')` is true → on iframe
|
|
||||||
load with no stored identity it opens the identity-picker modal. In the demo
|
|
||||||
this is a blocking modal the visitor shouldn't see.
|
|
||||||
- `appLauncher.ts openSession`: `IS_DEMO && isDemoExternal(appId)` is the only
|
|
||||||
thing forcing indeedhub external; `NEW_TAB_APP_IDS` is already bypassed when
|
|
||||||
`IS_DEMO && isDemoApp(appId)`. `AppSession.vue mustOpenNewTab` has the same
|
|
||||||
two-clause shape. Removing indeedhub from `DEMO_EXTERNAL_URLS` while keeping
|
|
||||||
`isDemoApp('indeedhub')` true flips it to the iframe path everywhere.
|
|
||||||
- `mock-backend.js`: per-visitor session state is initialized via
|
|
||||||
`md['package-data'] = structuredClone(staticDevApps)` (~line 5493), so
|
|
||||||
adding an entry to `staticDevApps` (~line 828) makes it installed on every
|
|
||||||
fresh session. `APP_PORTS`-style map at ~line 323 already has
|
|
||||||
`'indeedhub': 8190`; an icon exists at `/assets/img/app-icons/indeedhub.png`.
|
|
||||||
- `Dockerfile.web` copies `nginx-demo.conf` to `/etc/nginx/nginx.conf.template`
|
|
||||||
and runs `docker-entrypoint-custom.sh` (env substitution) — read the
|
|
||||||
entrypoint before editing so the new server block's nginx `$vars` survive
|
|
||||||
templating the same way the existing blocks' do.
|
|
||||||
- Both Docker builds already scrub + fail on any occurrence of the private
|
|
||||||
release-server IP; nothing in this change may hardcode host IPs — build the
|
|
||||||
iframe URL from `window.location.hostname`.
|
|
||||||
- `indeedhub/` at repo root is a git submodule (not checked out) — NEVER stage
|
|
||||||
any path under it. `indeedhub-demo/` is a prior standalone-build attempt
|
|
||||||
(clones the GitHub fork, builds with VITE env); this plan supersedes it by
|
|
||||||
proxying the LIVE site instead — leave that directory untouched.
|
|
||||||
</verified_findings>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 1: nginx whole-origin proxy on :2101 + sign-in seed script + compose ports</name>
|
|
||||||
<files>neode-ui/docker/nginx-demo.conf, neode-ui/docker/indee-demo-signin.js, neode-ui/Dockerfile.web, docker-compose.demo.yml, demo-deploy/docker-compose.yml, neode-ui/docker/docker-entrypoint.sh</files>
|
|
||||||
<action>
|
|
||||||
Add a second `server` block to nginx-demo.conf: `listen 2101;` that is a
|
|
||||||
pure whole-origin reverse proxy of `https://indee.tx1138.com` — no path
|
|
||||||
prefix, no URL rewriting (this is the fix for the documented sub_filter
|
|
||||||
breakage: the SPA keeps its own absolute-root paths). In that block:
|
|
||||||
`location / { proxy_pass https://indee.tx1138.com; }` with
|
|
||||||
`proxy_ssl_server_name on;`, `proxy_ssl_name indee.tx1138.com;`,
|
|
||||||
`proxy_set_header Host indee.tx1138.com;`,
|
|
||||||
`proxy_http_version 1.1;` + WebSocket upgrade headers (reuse the existing
|
|
||||||
`$connection_upgrade` map), `proxy_hide_header X-Frame-Options;` and
|
|
||||||
`proxy_hide_header Content-Security-Policy;`. For HTML injection:
|
|
||||||
`proxy_set_header Accept-Encoding "";` (upstream must not gzip or
|
|
||||||
sub_filter no-ops), `sub_filter_types text/html;`, `sub_filter_once on;`,
|
|
||||||
`sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';`
|
|
||||||
— a classic (non-module) script injected at end of head still executes
|
|
||||||
BEFORE the SPA's deferred module bundle, which is what the seeding needs.
|
|
||||||
Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }`
|
|
||||||
(or alias) inside the 2101 server so the seed script is served same-origin
|
|
||||||
to the iframe. Update the comment block that currently explains why
|
|
||||||
IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design.
|
|
||||||
|
|
||||||
Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic
|
|
||||||
script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and
|
|
||||||
that the embedded key is a freshly generated THROWAWAY demo identity, not
|
|
||||||
a real secret. Generate ONE fresh secp256k1 keypair at implementation time
|
|
||||||
(e.g. `node -e` with a tiny script using any available schnorr/secp lib, or
|
|
||||||
a one-off `npx` of nostr-tools in the scratchpad — the generator itself is
|
|
||||||
not committed) and embed hex sk + hex pk as constants. The script: if
|
|
||||||
`localStorage.getItem('indeedhub-accounts')` is empty/absent, write the
|
|
||||||
two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array
|
|
||||||
with ONE serialized private-key account: verify the exact `type` string
|
|
||||||
and common-field shape against the live bundle per verified_findings, shape
|
|
||||||
`{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields`
|
|
||||||
round-trips, give it a friendly name/metadata like "Archy Demo" if the
|
|
||||||
shape supports it) and `indeedhub-active-account` (that account's id).
|
|
||||||
Because the script runs on the :2101 origin inside the iframe, this
|
|
||||||
touches only the proxied app's isolated storage. IndeeHub then restores
|
|
||||||
the account on boot and self-signs with its own bundled signer — no
|
|
||||||
window.nostr and no parent bridge required. Do NOT define a partial
|
|
||||||
`window.nostr` in this approach (a pubkey-only shim with a broken
|
|
||||||
signEvent causes worse failures than no shim).
|
|
||||||
|
|
||||||
FALLBACK (only if live testing in Task-3 verification shows the seeded
|
|
||||||
account shape is not accepted): seed an `"extension"`-type account
|
|
||||||
instead, define a `window.nostr` postMessage client in this same script
|
|
||||||
(request/response protocol matching useNostrBridge: post
|
|
||||||
`{type:'nostr-request', id, method, params}` to `window.parent`, resolve on
|
|
||||||
`{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` /
|
|
||||||
`identity.nostr-sign` in mock-backend.js with real schnorr signatures over
|
|
||||||
the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is
|
|
||||||
pure JS and Dockerfile.backend runs `npm install` over package.json).
|
|
||||||
Prefer the primary approach; only fall back with evidence.
|
|
||||||
|
|
||||||
Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is
|
|
||||||
already inside `neode-ui/` so the existing `COPY neode-ui/ ./` +
|
|
||||||
dist copy do NOT ship it — add an explicit
|
|
||||||
`COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js`
|
|
||||||
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
|
|
||||||
never in real-node artifacts). Publish the port in docker-compose.demo.yml
|
|
||||||
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
|
|
||||||
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
|
|
||||||
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
|
|
||||||
file's header comment). Read docker-entrypoint.sh first and make sure the
|
|
||||||
new server block survives its template substitution exactly like the
|
|
||||||
existing blocks (same escaping convention for nginx `$` variables); touch
|
|
||||||
the entrypoint only if its substitution list needs it.
|
|
||||||
|
|
||||||
Do not put any host IP in any of these files; upstream hostname
|
|
||||||
indee.tx1138.com is fine.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
|
|
||||||
</verify>
|
|
||||||
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
|
|
||||||
<files>neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts</files>
|
|
||||||
<action>
|
|
||||||
In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete
|
|
||||||
the map entirely if it becomes empty, simplifying `isDemoExternal` to
|
|
||||||
return false — keep the exported function so call sites in appLauncher.ts
|
|
||||||
and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')`
|
|
||||||
return the proxied origin built at runtime:
|
|
||||||
`${window.location.protocol}//${window.location.hostname}:2101/`
|
|
||||||
(hostname, never a hardcoded host/IP — works on any deploy host). Keep
|
|
||||||
`isDemoApp('indeedhub')` true (it must stay in the demoable set so the
|
|
||||||
NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab
|
|
||||||
keeps routing it into the in-app iframe session, and so the install
|
|
||||||
button stays enabled). Update the file-header comment block that
|
|
||||||
currently documents the external-tab workaround to describe the :2101
|
|
||||||
whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA)
|
|
||||||
but guard `typeof window !== 'undefined'` if other tests import the module
|
|
||||||
in node context — check the existing unit tests under
|
|
||||||
src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions
|
|
||||||
about indeedhub being demo-external and update them to the new behavior.
|
|
||||||
|
|
||||||
In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from
|
|
||||||
useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`,
|
|
||||||
when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor
|
|
||||||
must not be interrupted by an identity modal (the embedded IndeeHub is
|
|
||||||
already signed in via the seeded account from Task 1, and `sendIdentity`'s
|
|
||||||
`identity.sign` RPC is not what logs it in). Real-node behavior
|
|
||||||
(picker on first launch) is untouched because IS_DEMO is compile-time
|
|
||||||
false there.
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions</name>
|
|
||||||
<files>neode-ui/mock-backend.js</files>
|
|
||||||
<action>
|
|
||||||
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
|
|
||||||
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
|
|
||||||
(match the existing title map at ~line 537 and APP_TITLES), a short/long
|
|
||||||
description consistent with the marketplace copy ("Bitcoin documentary
|
|
||||||
streaming platform" per the existing entry), `state: 'running'`,
|
|
||||||
`lanPort: 8190` (matches the existing port map), icon
|
|
||||||
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
|
|
||||||
`structuredClone(staticDevApps)`, this alone makes it installed+running on
|
|
||||||
every fresh session. Then reconcile the rest of the mock so nothing
|
|
||||||
contradicts installed status: check the marketplace/available-apps mock
|
|
||||||
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
|
|
||||||
4900+) for `indeedhub` entries that would render it as not-installed or
|
|
||||||
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
|
|
||||||
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
|
|
||||||
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
|
|
||||||
mock as-is unless Task 1's fallback path was taken (in which case align
|
|
||||||
its pubkey with the throwaway demo key and add the sign handlers described
|
|
||||||
there).
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
|
|
||||||
</verify>
|
|
||||||
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
|
|
||||||
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
|
|
||||||
| public visitors → demo host | anyone can drive the proxy |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
||||||
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
|
|
||||||
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
|
|
||||||
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
|
|
||||||
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
|
|
||||||
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
Local (executor, before commit):
|
|
||||||
1. nginx config parses (Task 1 verify).
|
|
||||||
2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build
|
|
||||||
unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of
|
|
||||||
the plain build — that is EXPECTED; the bundle-grep for demo strings must be
|
|
||||||
done on the VITE_DEMO=1 build, which is exactly what the demo Docker image
|
|
||||||
builds (Dockerfile.web defaults ARG VITE_DEMO=1).
|
|
||||||
3. Fresh-session mock package-data includes indeedhub (Task 3 verify).
|
|
||||||
4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up
|
|
||||||
--build` locally, browse http://localhost:2100 in a private window →
|
|
||||||
login `entertoexit` → IndeeHub installed → launch → iframe renders the
|
|
||||||
proxied site from http://localhost:2101 with a signed-in account.
|
|
||||||
5. `git status` — confirm nothing under indeedhub/ is staged, ever.
|
|
||||||
|
|
||||||
Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100):
|
|
||||||
1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options
|
|
||||||
header and the injected `indee-demo-signin.js` tag in the HTML body
|
|
||||||
(`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the
|
|
||||||
port is unreachable, the vps2 firewall needs 2101 opened — flag to
|
|
||||||
orchestrator.
|
|
||||||
2. Fresh private browser window → :2100 → login → IndeeHub shows installed/
|
|
||||||
running on the dashboard/My Apps without any install action.
|
|
||||||
3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a
|
|
||||||
new tab; content browsable; no identity-picker modal.
|
|
||||||
4. Signed-in check: IndeeHub header shows an active account (avatar/profile
|
|
||||||
instead of a sign-in button). If the seeded account shape was rejected
|
|
||||||
(login wall still visible), execute the documented fallback (extension
|
|
||||||
account + window.nostr shim + mock signer) and redeploy.
|
|
||||||
5. View-source/network spot-check: no occurrence of the private
|
|
||||||
release-server IP in any served response.
|
|
||||||
6. Repeat-visit check: reload the iframe once — a service worker registered by
|
|
||||||
IndeeHub may serve cached HTML without the injected tag on later loads;
|
|
||||||
that is acceptable because localStorage is already seeded on first load,
|
|
||||||
but confirm sign-in persists.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Demo visitor on a fresh browser sees IndeeHub installed, launches it into
|
|
||||||
the in-app iframe, and browses indee.tx1138.com content signed in — zero
|
|
||||||
clicks spent on install/login/identity modals.
|
|
||||||
- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated).
|
|
||||||
- No secrets committed beyond the labelled throwaway demo key; nothing staged
|
|
||||||
under indeedhub/; demo serves no private release-server IP.
|
|
||||||
- Work committed in focused commits (infra / frontend / mock) with the
|
|
||||||
Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to
|
|
||||||
the orchestrator.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done.
|
|
||||||
</output>
|
|
||||||
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 45
|
versionCode = 38
|
||||||
versionName = "0.5.25"
|
versionName = "0.5.18"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
BIN
Android/app/debug.keystore
Normal file
BIN
Android/app/debug.keystore
Normal file
Binary file not shown.
@ -5,10 +5,6 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@ -38,20 +34,6 @@ class ArchyVpnService : VpnService() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var warmerJob: Job? = null
|
private var warmerJob: Job? = null
|
||||||
|
|
||||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
|
||||||
// tunnel's underlying network stays pinned to the interface that was
|
|
||||||
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
|
|
||||||
// mesh sockets ride a dead network and sessions never recover until the
|
|
||||||
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
|
|
||||||
// the tunnel to the new default network via setUnderlyingNetworks and
|
|
||||||
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
|
|
||||||
// on the new path within seconds instead of waiting out dead-link
|
|
||||||
// timeouts.
|
|
||||||
private var connectivityManager: ConnectivityManager? = null
|
|
||||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
|
||||||
@Volatile
|
|
||||||
private var currentUnderlying: Network? = null
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
shutdown()
|
shutdown()
|
||||||
@ -131,7 +113,6 @@ class ArchyVpnService : VpnService() {
|
|||||||
shutdown()
|
shutdown()
|
||||||
} else {
|
} else {
|
||||||
startSessionWarmer()
|
startSessionWarmer()
|
||||||
registerNetworkHandoff()
|
|
||||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||||
// Mutual pairing: when a phone that scanned OUR QR announces
|
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||||
@ -172,28 +153,19 @@ class ArchyVpnService : VpnService() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}.distinct()
|
}.distinct()
|
||||||
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
|
for ((ula, port) in targets) {
|
||||||
// Probe all targets CONCURRENTLY with a short timeout — the
|
try {
|
||||||
// old sequential 20s-per-target loop let one cold node starve
|
java.net.Socket().use { s ->
|
||||||
// every other target for the whole aggressive window.
|
s.connect(
|
||||||
targets.map { (ula, port) ->
|
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
|
||||||
launch {
|
20_000,
|
||||||
try {
|
)
|
||||||
java.net.Socket().use { s ->
|
|
||||||
s.connect(
|
|
||||||
java.net.InetSocketAddress(
|
|
||||||
java.net.InetAddress.getByName(ula),
|
|
||||||
port,
|
|
||||||
),
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Cold path / node away — the attempt still drove
|
|
||||||
// session establishment; try again next round.
|
|
||||||
}
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Cold path / node away — the connect attempt still
|
||||||
|
// drove session establishment; try again next round.
|
||||||
}
|
}
|
||||||
}.forEach { it.join() }
|
}
|
||||||
round++
|
round++
|
||||||
// Aggressive for the first ~minute (session bring-up), then a
|
// Aggressive for the first ~minute (session bring-up), then a
|
||||||
// slow keep-warm tick that costs nearly nothing.
|
// slow keep-warm tick that costs nearly nothing.
|
||||||
@ -202,75 +174,8 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Track the phone's default network and hand the mesh over to it as the
|
|
||||||
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
|
||||||
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
|
||||||
* network instead of dying on the one it launched with.
|
|
||||||
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
|
||||||
* rebuild on the new path immediately; the node's own fast-reconnect
|
|
||||||
* (1s) redials peers over the new route.
|
|
||||||
* onAvailable also fires for the FIRST network, which is how the initial
|
|
||||||
* underlying network gets set.
|
|
||||||
*/
|
|
||||||
private fun registerNetworkHandoff() {
|
|
||||||
if (networkCallback != null) return
|
|
||||||
val cm = getSystemService(ConnectivityManager::class.java) ?: return
|
|
||||||
connectivityManager = cm
|
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
handoffTo(network)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
// The lost network was our underlying one — clear the pin so
|
|
||||||
// the system falls back to whatever default remains; the next
|
|
||||||
// onAvailable re-pins explicitly.
|
|
||||||
if (network == currentUnderlying) {
|
|
||||||
currentUnderlying = null
|
|
||||||
runCatching { setUnderlyingNetworks(null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
networkCallback = cb
|
|
||||||
// requestNetwork tracks the BEST network of the request; when the
|
|
||||||
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
|
|
||||||
// one. (registerDefaultNetworkCallback would also work; requestNetwork
|
|
||||||
// lets us extend to BLE-capable transports later.)
|
|
||||||
runCatching { cm.requestNetwork(request, cb) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handoffTo(network: Network) {
|
|
||||||
val changed = network != currentUnderlying
|
|
||||||
currentUnderlying = network
|
|
||||||
// Always re-assert; cheap and covers capability changes on the same
|
|
||||||
// Network object.
|
|
||||||
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
|
||||||
if (changed && FipsNative.isRunning()) {
|
|
||||||
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
|
||||||
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
|
||||||
// on the new path instead of waiting out dead-link timeouts.
|
|
||||||
startSessionWarmer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterNetworkHandoff() {
|
|
||||||
val cm = connectivityManager
|
|
||||||
val cb = networkCallback
|
|
||||||
if (cm != null && cb != null) {
|
|
||||||
runCatching { cm.unregisterNetworkCallback(cb) }
|
|
||||||
}
|
|
||||||
networkCallback = null
|
|
||||||
connectivityManager = null
|
|
||||||
currentUnderlying = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shutdown() {
|
private fun shutdown() {
|
||||||
warmerJob?.cancel()
|
warmerJob?.cancel()
|
||||||
unregisterNetworkHandoff()
|
|
||||||
FlareServer.stop()
|
FlareServer.stop()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
@ -278,7 +183,6 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterNetworkHandoff()
|
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@ -41,15 +41,7 @@ object FipsManager {
|
|||||||
ensureIdentity(prefs)
|
ensureIdentity(prefs)
|
||||||
prefs.upsertNodePeer(info, alias)
|
prefs.upsertNodePeer(info, alias)
|
||||||
peersDirty = true
|
peersDirty = true
|
||||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
_consentNeeded.value = true
|
||||||
// exists — relying on the consentNeeded collector left a running
|
|
||||||
// mesh on the OLD peer list whenever the collector wasn't active
|
|
||||||
// (fresh pairings looked dead until a full app restart).
|
|
||||||
if (VpnService.prepare(context) == null) {
|
|
||||||
startService(context)
|
|
||||||
} else {
|
|
||||||
_consentNeeded.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Palette
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -109,7 +108,6 @@ fun NESController(
|
|||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
onToggleStyle: (() -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@ -207,7 +205,6 @@ fun NESController(
|
|||||||
) {
|
) {
|
||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -434,23 +431,6 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
|
|
||||||
* no longer carries it). */
|
|
||||||
@Composable
|
|
||||||
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
|
||||||
var p by remember { mutableStateOf(false) }
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (p) c.capsulePress else c.capsule)
|
|
||||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Player ID toggle pill (P1/P2/ALL) */
|
/** Player ID toggle pill (P1/P2/ALL) */
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||||
|
|||||||
@ -21,40 +21,20 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Bolt
|
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
|
||||||
import androidx.compose.material.icons.filled.Dns
|
|
||||||
import androidx.compose.material.icons.filled.Groups
|
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import com.archipelago.app.fips.FipsManager
|
|
||||||
import com.archipelago.app.fips.FipsNative
|
|
||||||
import com.archipelago.app.fips.FipsPreferences
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -71,6 +51,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.archipelago.app.ui.theme.ControllerStyle
|
||||||
import com.archipelago.app.ui.theme.SurfaceDark
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
@ -94,29 +75,27 @@ fun NESMenu(
|
|||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
|
isGamepadMode: Boolean,
|
||||||
|
controllerStyle: ControllerStyle,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
onScanQr: (() -> Unit)? = null,
|
onScanQr: (() -> Unit)? = null,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onRemote: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
onKeyboard: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> Unit)? = null,
|
onMeshParty: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
|
||||||
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
|
||||||
// inside its own bounds when content is tall. Tapping the dimmed
|
|
||||||
// backdrop dismisses.
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,14 +105,16 @@ fun NESMenu(
|
|||||||
private fun MenuPanel(
|
private fun MenuPanel(
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
|
isGamepadMode: Boolean,
|
||||||
|
controllerStyle: ControllerStyle,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
onScanQr: (() -> Unit)?,
|
onScanQr: (() -> Unit)?,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onRemote: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
onKeyboard: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
@ -143,15 +124,14 @@ private fun MenuPanel(
|
|||||||
var nm by remember { mutableStateOf("") }
|
var nm by remember { mutableStateOf("") }
|
||||||
var addr by remember { mutableStateOf("") }
|
var addr by remember { mutableStateOf("") }
|
||||||
var pwd by remember { mutableStateOf("") }
|
var pwd by remember { mutableStateOf("") }
|
||||||
var https by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun resetForm() {
|
fun resetForm() {
|
||||||
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
|
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startEdit(server: ServerEntry) {
|
fun startEdit(server: ServerEntry) {
|
||||||
editing = server
|
editing = server
|
||||||
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
|
nm = server.name; addr = server.address; pwd = server.password
|
||||||
showAdd = false
|
showAdd = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,381 +139,161 @@ private fun MenuPanel(
|
|||||||
if (addr.isBlank()) return
|
if (addr.isBlank()) return
|
||||||
val orig = editing
|
val orig = editing
|
||||||
if (orig != null) {
|
if (orig != null) {
|
||||||
// Preserve port (compact form doesn't expose it); scheme is now editable.
|
// Preserve fields the compact form doesn't expose (scheme, port).
|
||||||
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
|
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
||||||
} else {
|
} else {
|
||||||
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
|
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
var page by remember { mutableStateOf(HubPage.HUB) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
// Cap height just short of the full screen; the panel wraps short
|
|
||||||
// content and only scrolls in the rare case it outgrows this.
|
|
||||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
|
||||||
.clip(RoundedCornerShape(PANEL_R))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg.copy(alpha = 0.86f))
|
.background(PanelBg)
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||||
.verticalScroll(rememberScrollState())
|
.padding(22.dp),
|
||||||
.padding(20.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
// Header: back (on sub-pages) or title, and a close on the hub.
|
// Title
|
||||||
Row(
|
Text(
|
||||||
Modifier.fillMaxWidth(),
|
"Menu",
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
color = TextPrimary,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
fontSize = 18.sp,
|
||||||
) {
|
fontWeight = FontWeight.SemiBold,
|
||||||
if (page == HubPage.HUB) {
|
letterSpacing = 2.sp,
|
||||||
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
textAlign = TextAlign.Center,
|
||||||
} else {
|
)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Spacer(Modifier.height(2.dp))
|
||||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
|
||||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
|
|
||||||
when (page) {
|
// Servers
|
||||||
HubPage.HUB -> {
|
servers.forEach { server ->
|
||||||
// Card page — one card per destination. Dashboard first: it's a
|
val active = server.serialize() == activeServer?.serialize()
|
||||||
// peer of the others so three-finger → hub → Dashboard returns
|
MenuItem(
|
||||||
// to the node UI, same shape as every other option.
|
label = server.displayName(),
|
||||||
if (onBackToWebView != null) {
|
selected = active,
|
||||||
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
|
onClick = { onSelectServer(server) },
|
||||||
}
|
onEdit = { startEdit(server) },
|
||||||
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
onRemove = { onRemoveServer(server) },
|
||||||
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
|
||||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
|
||||||
page = HubPage.NODES
|
|
||||||
}
|
|
||||||
if (FipsNative.available) {
|
|
||||||
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
|
||||||
}
|
|
||||||
if (onMeshParty != null) {
|
|
||||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
|
||||||
}
|
|
||||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
|
||||||
// the settings button — not here.
|
|
||||||
}
|
|
||||||
|
|
||||||
HubPage.NODES -> {
|
|
||||||
servers.forEach { server ->
|
|
||||||
val active = server.serialize() == activeServer?.serialize()
|
|
||||||
MenuItem(
|
|
||||||
label = server.displayName(),
|
|
||||||
selected = active,
|
|
||||||
onClick = { onSelectServer(server) },
|
|
||||||
onEdit = { startEdit(server) },
|
|
||||||
onRemove = { onRemoveServer(server) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (servers.isEmpty()) {
|
|
||||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAdd || editing != null) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
|
||||||
.background(FieldBg)
|
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (editing != null) "Edit Server" else "Add Server",
|
|
||||||
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Cancel", color = TextMuted, fontSize = 13.sp,
|
|
||||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GlassField(
|
|
||||||
value = nm, onValueChange = { nm = it },
|
|
||||||
placeholder = "Name (optional)",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
GlassField(
|
|
||||||
value = addr, onValueChange = { addr = it.trim() },
|
|
||||||
placeholder = "192.168.1.100",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
GlassField(
|
|
||||||
value = pwd, onValueChange = { pwd = it },
|
|
||||||
placeholder = "Password",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
|
||||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
|
||||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
|
||||||
.clickable { submit() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
|
||||||
}
|
|
||||||
// HTTPS scheme toggle (available on both add and edit).
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
|
||||||
.clickable { https = !https }
|
|
||||||
.padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.width(46.dp).height(26.dp)
|
|
||||||
.clip(RoundedCornerShape(13.dp))
|
|
||||||
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
|
|
||||||
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
|
|
||||||
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 3.dp)
|
|
||||||
.size(20.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(if (https) Color.White else TextMuted),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
|
||||||
}
|
|
||||||
if (onScanQr != null) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(ROW_H)
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
|
||||||
.background(RowBg)
|
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
|
||||||
.clickable { onScanQr() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.QrCodeScanner,
|
|
||||||
contentDescription = stringResource(R.string.add_server_qr),
|
|
||||||
tint = BitcoinOrange,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HubPage.FIPS -> {
|
|
||||||
FipsSection(embedded = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class HubPage { HUB, NODES, FIPS }
|
|
||||||
|
|
||||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
|
||||||
@Composable
|
|
||||||
private fun HubCard(
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
|
||||||
.background(RowBg)
|
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small circular icon button used in the hub header. */
|
|
||||||
@Composable
|
|
||||||
private fun IconRound(
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
desc: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(RowBg)
|
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
|
|
||||||
.clickable { onClick() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
|
||||||
private data class FipsInfo(
|
|
||||||
val available: Boolean,
|
|
||||||
val running: Boolean,
|
|
||||||
val npub: String,
|
|
||||||
val meshAddress: String,
|
|
||||||
val peerCount: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
|
|
||||||
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
|
|
||||||
* many peers/anchors it's configured with — and a one-tap Reconnect that
|
|
||||||
* re-homes the mesh (also the manual fix if a network handoff ever misses).
|
|
||||||
* Collapsed by default so the menu stays compact.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun FipsSection(embedded: Boolean = false) {
|
|
||||||
if (!FipsNative.available) return
|
|
||||||
val context = LocalContext.current
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
var expanded by remember { mutableStateOf(embedded) }
|
|
||||||
var info by remember { mutableStateOf<FipsInfo?>(null) }
|
|
||||||
|
|
||||||
// Load identity/state when the section opens (cheap DataStore + JSON read).
|
|
||||||
LaunchedEffect(expanded) {
|
|
||||||
if (expanded && info == null) {
|
|
||||||
val prefs = FipsPreferences(context)
|
|
||||||
val id = prefs.identity()
|
|
||||||
val peers = runCatching {
|
|
||||||
org.json.JSONArray(prefs.peersJson()).length()
|
|
||||||
}.getOrDefault(0)
|
|
||||||
info = FipsInfo(
|
|
||||||
available = true,
|
|
||||||
running = FipsNative.isRunning(),
|
|
||||||
npub = id?.npub.orEmpty(),
|
|
||||||
meshAddress = id?.address.orEmpty(),
|
|
||||||
peerCount = peers,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
if (servers.isEmpty()) {
|
||||||
// Embedded in the hub's FIPS sub-page the header row would duplicate
|
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
// the page title, so only the standalone (collapsible) form shows it.
|
}
|
||||||
if (!embedded) MenuItem(
|
|
||||||
label = "FIPS Mesh",
|
// Add / edit server
|
||||||
labelColor = BitcoinOrange,
|
if (showAdd || editing != null) {
|
||||||
onClick = { expanded = !expanded },
|
|
||||||
)
|
|
||||||
if (expanded) {
|
|
||||||
val i = info
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 6.dp)
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
.background(FieldBg)
|
.background(FieldBg)
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.padding(14.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (i == null) {
|
Row(
|
||||||
Text("Loading…", color = TextMuted, fontSize = 13.sp)
|
Modifier.fillMaxWidth(),
|
||||||
} else {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
FipsRow("Status", if (i.running) "Connected" else "Stopped",
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
valueColor = if (i.running) BitcoinOrange else TextMuted)
|
) {
|
||||||
if (i.meshAddress.isNotBlank()) {
|
|
||||||
FipsRow("Mesh address", i.meshAddress, mono = true,
|
|
||||||
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
|
|
||||||
}
|
|
||||||
if (i.npub.isNotBlank()) {
|
|
||||||
FipsRow("Identity (npub)", i.npub, mono = true,
|
|
||||||
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
|
|
||||||
}
|
|
||||||
FipsRow("Peers & anchors", i.peerCount.toString())
|
|
||||||
Text(
|
Text(
|
||||||
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
|
if (editing != null) "Edit Server" else "Add Server",
|
||||||
color = TextMuted, fontSize = 11.sp,
|
color = TextMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
MenuItem(
|
Text(
|
||||||
label = "Reconnect mesh",
|
"Cancel",
|
||||||
labelColor = BitcoinOrange,
|
color = TextMuted,
|
||||||
onClick = {
|
fontSize = 13.sp,
|
||||||
FipsManager.requestMeshRestart(context)
|
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||||
info = null
|
|
||||||
if (!embedded) expanded = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
GlassField(
|
||||||
|
value = nm, onValueChange = { nm = it },
|
||||||
|
placeholder = "Name (optional)",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
GlassField(
|
||||||
|
value = addr, onValueChange = { addr = it.trim() },
|
||||||
|
placeholder = "192.168.1.100",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GlassField(
|
||||||
|
value = pwd, onValueChange = { pwd = it },
|
||||||
|
placeholder = "Password",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||||
|
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||||
|
.clickable { submit() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||||
|
}
|
||||||
|
if (onScanQr != null) {
|
||||||
|
// Add server by scanning the node's pairing QR
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(ROW_H)
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { onScanQr() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.QrCodeScanner,
|
||||||
|
contentDescription = stringResource(R.string.add_server_qr),
|
||||||
|
tint = BitcoinOrange,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
Spacer(Modifier.height(2.dp))
|
||||||
private fun FipsRow(
|
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||||
label: String,
|
Spacer(Modifier.height(2.dp))
|
||||||
value: String,
|
|
||||||
valueColor: Color = TextPrimary,
|
// Mode toggle
|
||||||
mono: Boolean = false,
|
MenuItem(
|
||||||
onCopy: (() -> Unit)? = null,
|
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||||
) {
|
onClick = onToggleMode,
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
color = valueColor,
|
|
||||||
fontSize = if (mono) 11.sp else 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
)
|
)
|
||||||
if (onCopy != null) {
|
|
||||||
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
// Style toggle
|
||||||
|
MenuItem(
|
||||||
|
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||||
|
onClick = onToggleStyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phone↔phone mesh pairing + chat
|
||||||
|
if (onMeshParty != null) {
|
||||||
|
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to dashboard
|
||||||
|
if (onBackToWebView != null) {
|
||||||
|
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,6 @@ fun NESPortraitController(
|
|||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
onToggleStyle: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@ -152,10 +151,6 @@ fun NESPortraitController(
|
|||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
onToggleStyle?.let {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
StyleBtn(c, Modifier, it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -238,7 +238,6 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||||
var provider: ProcessCameraProvider? = null
|
var provider: ProcessCameraProvider? = null
|
||||||
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
|
|
||||||
|
|
||||||
providerFuture.addListener({
|
providerFuture.addListener({
|
||||||
val p = providerFuture.get()
|
val p = providerFuture.get()
|
||||||
@ -246,15 +245,12 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
}
|
}
|
||||||
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||||
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||||
// lens, which won't focus close) left dense invoices undecodable
|
// doubles the pixel density at negligible analysis cost.
|
||||||
// while sparse address QRs still read — the "scanner doesn't pick up
|
|
||||||
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
|
||||||
// QR held at the camera's actual focus distance still resolves.
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val analysis = ImageAnalysis.Builder()
|
val analysis = ImageAnalysis.Builder()
|
||||||
.setTargetResolution(android.util.Size(1920, 1080))
|
.setTargetResolution(android.util.Size(1280, 720))
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
.also {
|
.also {
|
||||||
@ -265,27 +261,13 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
p.unbindAll()
|
p.unbindAll()
|
||||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
|
||||||
// a static scene, so continuous-AF often never retriggers and the
|
|
||||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
|
||||||
// A normalized centre point works before the view is measured.
|
|
||||||
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
|
||||||
.createPoint(0.5f, 0.5f)
|
|
||||||
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
|
||||||
point,
|
|
||||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
|
||||||
).disableAutoCancel().build()
|
|
||||||
focusScheduler.scheduleWithFixedDelay({
|
|
||||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
|
||||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Camera unavailable — the user can dismiss and enter details manually.
|
// Camera unavailable — the user can dismiss and enter details manually.
|
||||||
}
|
}
|
||||||
}, mainExecutor)
|
}, mainExecutor)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
focusScheduler.shutdownNow()
|
|
||||||
provider?.unbindAll()
|
provider?.unbindAll()
|
||||||
analysisExecutor.shutdown()
|
analysisExecutor.shutdown()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,11 +12,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.archipelago.app.data.PairResult
|
import com.archipelago.app.data.PairResult
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
@ -179,25 +177,11 @@ fun AppNavHost(
|
|||||||
onRemoteInput = {
|
onRemoteInput = {
|
||||||
navController.navigate(Routes.REMOTE_INPUT)
|
navController.navigate(Routes.REMOTE_INPUT)
|
||||||
},
|
},
|
||||||
onRemoteKeyboard = {
|
|
||||||
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
|
|
||||||
},
|
|
||||||
onMeshParty = {
|
|
||||||
navController.navigate(Routes.MESH_PARTY)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(Routes.REMOTE_INPUT) {
|
||||||
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("keyboard") {
|
|
||||||
type = NavType.BoolType
|
|
||||||
defaultValue = false
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) { entry ->
|
|
||||||
RemoteInputScreen(
|
RemoteInputScreen(
|
||||||
onBack = {
|
onBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
@ -205,7 +189,6 @@ fun AppNavHost(
|
|||||||
onMeshParty = {
|
onMeshParty = {
|
||||||
navController.navigate(Routes.MESH_PARTY)
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
},
|
},
|
||||||
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,8 @@ import android.content.res.Configuration
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -56,12 +54,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteInputScreen(
|
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||||
onBack: () -> Unit,
|
|
||||||
onMeshParty: (() -> Unit)? = null,
|
|
||||||
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
|
|
||||||
startInKeyboard: Boolean = false,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -70,7 +63,7 @@ fun RemoteInputScreen(
|
|||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||||
|
|
||||||
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
|
var isGamepadMode by remember { mutableStateOf(true) }
|
||||||
var showModal by remember { mutableStateOf(false) }
|
var showModal by remember { mutableStateOf(false) }
|
||||||
var showQrScanner by remember { mutableStateOf(false) }
|
var showQrScanner by remember { mutableStateOf(false) }
|
||||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||||
@ -97,9 +90,6 @@ fun RemoteInputScreen(
|
|||||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||||
ws.playerId = playerId
|
ws.playerId = playerId
|
||||||
}
|
}
|
||||||
fun toggleStyle() {
|
|
||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
|
||||||
}
|
|
||||||
val connectionState by ws.state.collectAsState()
|
val connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@ -163,7 +153,6 @@ fun RemoteInputScreen(
|
|||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
onToggleStyle = ::toggleStyle,
|
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
@ -174,7 +163,6 @@ fun RemoteInputScreen(
|
|||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
onToggleStyle = ::toggleStyle,
|
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
@ -194,20 +182,12 @@ fun RemoteInputScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Settings + style icons top-right in keyboard mode
|
// Settings icon top-right in keyboard mode
|
||||||
Row(
|
com.archipelago.app.ui.components.SettingsBtn(
|
||||||
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
) {
|
onClick = { showModal = true },
|
||||||
com.archipelago.app.ui.components.SettingsBtn(
|
)
|
||||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
|
||||||
onClick = { showModal = true },
|
|
||||||
)
|
|
||||||
com.archipelago.app.ui.components.StyleBtn(
|
|
||||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
|
||||||
onClick = ::toggleStyle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,6 +210,8 @@ fun RemoteInputScreen(
|
|||||||
visible = showModal,
|
visible = showModal,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
activeServer = activeServer,
|
activeServer = activeServer,
|
||||||
|
isGamepadMode = isGamepadMode,
|
||||||
|
controllerStyle = controllerStyle,
|
||||||
onDismiss = { showModal = false },
|
onDismiss = { showModal = false },
|
||||||
onSelectServer = { server ->
|
onSelectServer = { server ->
|
||||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||||
@ -263,8 +245,10 @@ fun RemoteInputScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRemote = { isGamepadMode = true; showModal = false },
|
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||||
onKeyboard = { isGamepadMode = false; showModal = false },
|
onToggleStyle = {
|
||||||
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
|
},
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
onBackToWebView = { showModal = false; onBack() },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -88,11 +88,8 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
import com.archipelago.app.fips.FipsManager
|
|
||||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||||
import com.archipelago.app.ui.components.NESMenu
|
|
||||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ErrorRed
|
import com.archipelago.app.ui.theme.ErrorRed
|
||||||
@ -182,78 +179,6 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||||
// Vue components sample the var into reactive state; tell them it
|
|
||||||
// changed (an authenticated session can mount before we run).
|
|
||||||
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
|
|
||||||
})();
|
|
||||||
""".trimIndent(),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-app browser pages (node apps + same-node links) don't consume the
|
|
||||||
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
|
||||||
* their content ran up under the status bar. Pad the document body down by
|
|
||||||
* the status-bar height: the padded strip shows the page's OWN background
|
|
||||||
* (padding is inside the element), so the bar keeps the page colour while
|
|
||||||
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
|
||||||
*
|
|
||||||
* Body padding only moves normal-flow content. fixed/sticky elements anchored
|
|
||||||
* at the viewport top (IndeeHub's floating header) stayed glued under the
|
|
||||||
* status bar, so we also push each of those down by the inset — once, marked
|
|
||||||
* via data attribute — and keep a throttled MutationObserver running so
|
|
||||||
* headers an SPA mounts after load get the same treatment.
|
|
||||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
|
||||||
private fun injectTopInset(view: WebView) {
|
|
||||||
val insets = view.rootWindowInsets ?: return
|
|
||||||
val density = view.resources.displayMetrics.density
|
|
||||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
|
||||||
if (sat <= 0) return
|
|
||||||
view.evaluateJavascript(
|
|
||||||
"""
|
|
||||||
(function() {
|
|
||||||
var SAT = $sat;
|
|
||||||
var s = document.getElementById('archy-top-inset');
|
|
||||||
if (!s) {
|
|
||||||
s = document.createElement('style');
|
|
||||||
s.id = 'archy-top-inset';
|
|
||||||
(document.head || document.documentElement).appendChild(s);
|
|
||||||
}
|
|
||||||
s.textContent =
|
|
||||||
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
|
|
||||||
function push(el) {
|
|
||||||
if (el.dataset.archyInset) return;
|
|
||||||
var cs = getComputedStyle(el);
|
|
||||||
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
|
||||||
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
|
||||||
if (isNaN(top) || top >= SAT) return;
|
|
||||||
el.style.setProperty('top', (top + SAT) + 'px', 'important');
|
|
||||||
el.dataset.archyInset = '1';
|
|
||||||
}
|
|
||||||
function sweep() {
|
|
||||||
if (!document.body) return;
|
|
||||||
// Fixed/sticky bars live shallow in the tree (portals mount on
|
|
||||||
// body); depth cap keeps the computed-style pass off big lists.
|
|
||||||
var els = document.body.querySelectorAll(
|
|
||||||
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
|
||||||
for (var i = 0; i < els.length; i++) push(els[i]);
|
|
||||||
}
|
|
||||||
sweep();
|
|
||||||
if (!window.__archyInsetObserver) {
|
|
||||||
var queued = false, last = 0;
|
|
||||||
window.__archyInsetObserver = new MutationObserver(function() {
|
|
||||||
if (queued) return;
|
|
||||||
queued = true;
|
|
||||||
var wait = Math.max(0, 250 - (Date.now() - last));
|
|
||||||
setTimeout(function() {
|
|
||||||
queued = false;
|
|
||||||
last = Date.now();
|
|
||||||
sweep();
|
|
||||||
}, wait);
|
|
||||||
});
|
|
||||||
window.__archyInsetObserver.observe(document.documentElement,
|
|
||||||
{ childList: true, subtree: true });
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
null,
|
null,
|
||||||
@ -273,17 +198,13 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||||
* (patient — a cold session may still be establishing). If NEITHER answers,
|
* (patient — a cold session may still be establishing), else LAN anyway so
|
||||||
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
* the existing error/fallback path handles it. */
|
||||||
* unreachable, and loading it just produced a confusing "can't reach
|
|
||||||
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
|
||||||
* instead means the load retries against the path that's actually coming up,
|
|
||||||
* and any error shows the mesh address rather than a dead LAN IP. */
|
|
||||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||||
meshUrl ?: lanUrl
|
lanUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||||
@ -324,10 +245,6 @@ fun WebViewScreen(
|
|||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
onRemoteInput: () -> Unit = {},
|
onRemoteInput: () -> Unit = {},
|
||||||
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
|
|
||||||
onRemoteKeyboard: () -> Unit = {},
|
|
||||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
|
||||||
onMeshParty: (() -> Unit)? = null,
|
|
||||||
// Stored password for this server (from QR pairing or manual entry). When
|
// Stored password for this server (from QR pairing or manual entry). When
|
||||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||||
// demo flow from docs/companion-pairing-qr.md.
|
// demo flow from docs/companion-pairing-qr.md.
|
||||||
@ -406,14 +323,6 @@ fun WebViewScreen(
|
|||||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||||
// it while DataStore is still loading).
|
// it while DataStore is still loading).
|
||||||
val prefs = remember { ServerPreferences(webViewContext) }
|
val prefs = remember { ServerPreferences(webViewContext) }
|
||||||
|
|
||||||
// Hub menu overlay state — the three-finger hold opens the menu right here
|
|
||||||
// over the dashboard (it used to jump to the remote screen).
|
|
||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
|
||||||
var showHubMenu by remember { mutableStateOf(false) }
|
|
||||||
var showPairScanner by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||||
@ -799,8 +708,7 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Three-finger hold (500ms) → open the hub menu overlay
|
// Three-finger hold (500ms) → navigate to remote input.
|
||||||
// in place (Remote/Keyboard cards do the navigating).
|
|
||||||
// Three fingers, not two: two-finger scroll/pinch on the
|
// Three fingers, not two: two-finger scroll/pinch on the
|
||||||
// page collided with the old two-finger hold.
|
// page collided with the old two-finger hold.
|
||||||
var threeFingerStart = 0L
|
var threeFingerStart = 0L
|
||||||
@ -818,7 +726,7 @@ fun WebViewScreen(
|
|||||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||||
threeFingerFired = true
|
threeFingerFired = true
|
||||||
showHubMenu = true
|
onRemoteInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -943,68 +851,6 @@ fun WebViewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub menu overlay — opened by the three-finger hold, drawn above
|
|
||||||
// everything (also reachable from the error screen, where switching
|
|
||||||
// servers is exactly what's needed).
|
|
||||||
NESMenu(
|
|
||||||
visible = showHubMenu,
|
|
||||||
servers = savedServers,
|
|
||||||
activeServer = activeServer,
|
|
||||||
onDismiss = { showHubMenu = false },
|
|
||||||
onSelectServer = { server ->
|
|
||||||
showHubMenu = false
|
|
||||||
scope.launch { prefs.setActiveServer(server) }
|
|
||||||
},
|
|
||||||
onAddServer = { server ->
|
|
||||||
scope.launch {
|
|
||||||
prefs.addSavedServer(server)
|
|
||||||
if (activeServer == null) prefs.setActiveServer(server)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onScanQr = { showPairScanner = true },
|
|
||||||
onEditServer = { original, updated ->
|
|
||||||
scope.launch {
|
|
||||||
prefs.updateSavedServer(original, updated)
|
|
||||||
// Editing the live server reloads the kiosk with the new
|
|
||||||
// address/credentials via the activeServer recomposition.
|
|
||||||
if (original.serialize() == activeServer?.serialize()) {
|
|
||||||
prefs.setActiveServer(updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemoveServer = { server ->
|
|
||||||
scope.launch {
|
|
||||||
prefs.removeSavedServer(server)
|
|
||||||
// Nothing left to show — back to the Connect screen.
|
|
||||||
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
|
||||||
if (remaining == 0) {
|
|
||||||
prefs.clearActiveServer()
|
|
||||||
showHubMenu = false
|
|
||||||
onDisconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemote = { showHubMenu = false; onRemoteInput() },
|
|
||||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
|
||||||
onBackToWebView = { showHubMenu = false },
|
|
||||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
|
||||||
// open behind it so the new entry appears as soon as it closes.
|
|
||||||
QrScannerOverlay(
|
|
||||||
visible = showPairScanner,
|
|
||||||
onDismiss = { showPairScanner = false },
|
|
||||||
onServerScanned = { scan ->
|
|
||||||
showPairScanner = false
|
|
||||||
scope.launch {
|
|
||||||
val merged = prefs.upsertServer(scan.server)
|
|
||||||
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
|
|
||||||
if (activeServer == null) prefs.setActiveServer(merged)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1051,17 +897,7 @@ private fun InAppBrowser(
|
|||||||
fun isSameNode(u: String): Boolean =
|
fun isSameNode(u: String): Boolean =
|
||||||
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
||||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||||
// Loader title: never show a raw IP host — a mesh ULA like
|
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||||
// [fd79:1aa:…] is technically the host but reads as garbage on the
|
|
||||||
// loading screen. Show a neutral name until the page reports its
|
|
||||||
// real <title> (onReceivedTitle upgrades it).
|
|
||||||
var title by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
android.net.Uri.parse(url).host
|
|
||||||
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
|
||||||
?: "Archipelago",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
@ -1099,21 +935,12 @@ private fun InAppBrowser(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack)
|
.background(SurfaceBlack)
|
||||||
// Whole-overlay touch shield: every touch not handled by a child
|
// Bottom inset handled by the touch-shield strip below the bar —
|
||||||
// (control-bar gaps, inset strips) dies here instead of falling
|
// NOT by padding: a padded area only paints, it doesn't consume,
|
||||||
// through to the kiosk's tab bar behind (a near-miss on Close
|
// so taps in the gesture strip fell straight THROUGH this overlay
|
||||||
// was opening the AIUI tab underneath).
|
// into the kiosk's tab bar behind it (accidental AIUI-tab hits).
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = {},
|
|
||||||
)
|
|
||||||
// Bottom inset handled by the touch-shield strip below the bar.
|
|
||||||
// No TOP inset padding: the WebView draws edge-to-edge behind the
|
|
||||||
// status bar so the app's own background fills it — the padded
|
|
||||||
// version painted an opaque black bar there (user-rejected look).
|
|
||||||
.windowInsetsPadding(
|
.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// WebView + loading overlay fill the area above the bottom control bar.
|
// WebView + loading overlay fill the area above the bottom control bar.
|
||||||
@ -1165,14 +992,12 @@ private fun InAppBrowser(
|
|||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
loading = true
|
loading = true
|
||||||
view?.let { injectTopInset(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
loading = false
|
loading = false
|
||||||
canGoBack = view?.canGoBack() == true
|
canGoBack = view?.canGoBack() == true
|
||||||
canGoForward = view?.canGoForward() == true
|
canGoForward = view?.canGoForward() == true
|
||||||
view?.let { injectTopInset(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@ -1,36 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v1.7.118-alpha (2026-07-29)
|
|
||||||
|
|
||||||
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
|
|
||||||
- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.
|
|
||||||
- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.
|
|
||||||
- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)
|
|
||||||
|
|
||||||
## v1.7.117-alpha (2026-07-29)
|
|
||||||
|
|
||||||
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
|
|
||||||
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
|
|
||||||
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
|
|
||||||
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
|
|
||||||
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
|
|
||||||
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
|
|
||||||
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
|
|
||||||
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
|
|
||||||
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
|
|
||||||
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
|
|
||||||
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
|
|
||||||
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
|
|
||||||
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
|
|
||||||
- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
|
|
||||||
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
|
|
||||||
|
|
||||||
## v1.7.116-alpha (2026-07-27)
|
|
||||||
|
|
||||||
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
|
|
||||||
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
|
|
||||||
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
|
|
||||||
|
|
||||||
## v1.7.115-alpha (2026-07-26)
|
## v1.7.115-alpha (2026-07-26)
|
||||||
|
|
||||||
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
# Code of Conduct
|
|
||||||
|
|
||||||
## Our standard
|
|
||||||
|
|
||||||
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
|
|
||||||
harassment, personal attacks, and discriminatory language are not.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This code of conduct applies to project repositories, issue trackers, pull
|
|
||||||
requests, documentation, chat, and community spaces connected to Archipelago.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Maintainers may edit, hide, or remove comments and may restrict participation
|
|
||||||
for behavior that makes collaboration unsafe or unproductive.
|
|
||||||
|
|
||||||
Report conduct concerns privately through the repository owner account or the
|
|
||||||
private contact channel listed on the project homepage.
|
|
||||||
191
CONTRIBUTING.md
191
CONTRIBUTING.md
@ -1,100 +1,161 @@
|
|||||||
# Contributing to Archipelago
|
# Contributing to Archipelago
|
||||||
|
|
||||||
This project is preparing for public developer contribution. The highest-value
|
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
|
||||||
contributions are focused fixes, tests, app manifests, documentation
|
|
||||||
improvements, and clear bug reports with reproducible evidence.
|
|
||||||
|
|
||||||
## Development setup
|
## Code of Conduct
|
||||||
|
|
||||||
### Frontend
|
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository on the project's Gitea instance
|
||||||
|
2. Clone your fork: `git clone <your-fork-url>/archy.git`
|
||||||
|
3. Set up the dev environment (see `docs/developer-guide.md`)
|
||||||
|
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Frontend (Vue.js)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start # Dev server on :8100
|
||||||
npm run type-check
|
npm run type-check # TypeScript validation
|
||||||
npm test
|
npm run build # Production build
|
||||||
|
npm test # Run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend
|
### Backend (Rust)
|
||||||
|
|
||||||
|
Build on a Linux server (Debian 13), **not** macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core
|
cargo clippy --all-targets --all-features
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux is required for host integration work involving Podman, systemd,
|
### Deploy to dev server
|
||||||
networking, or image builds. Frontend development works locally with the mock
|
|
||||||
backend.
|
|
||||||
|
|
||||||
## App manifests
|
|
||||||
|
|
||||||
App packages live under `apps/<app-id>/manifest.yml` and use the schema
|
|
||||||
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
|
|
||||||
before submitting:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
|
./scripts/deploy-to-target.sh --live
|
||||||
python3 scripts/generate-app-catalog.py
|
|
||||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
||||||
```
|
```
|
||||||
|
|
||||||
App submissions must:
|
## Code Style
|
||||||
|
|
||||||
- pin container image versions;
|
### Frontend (TypeScript + Vue)
|
||||||
- avoid hardcoded secrets;
|
|
||||||
- use `security.no_new_privileges: true`;
|
|
||||||
- use `security.readonly_root: true` unless the manifest explains why writable
|
|
||||||
root is required;
|
|
||||||
- request only necessary Linux capabilities;
|
|
||||||
- store durable data under `/var/lib/archipelago/<app-id>/`;
|
|
||||||
- define truthful health checks and launch interfaces for user-facing UIs.
|
|
||||||
|
|
||||||
## Code style
|
- `<script setup lang="ts">` — always Composition API
|
||||||
|
- TypeScript strict mode — no `any`, use `unknown` or proper types
|
||||||
|
- Global CSS classes in `src/style.css` — never inline Tailwind in components
|
||||||
|
- Pinia for state management — focused single-purpose stores
|
||||||
|
- Use `@/api/rpc-client.ts` for RPC calls
|
||||||
|
|
||||||
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
|
### Backend (Rust)
|
||||||
- Rust: use `tracing` for structured logs.
|
|
||||||
- TypeScript: avoid `any`; use explicit types or `unknown`.
|
|
||||||
- Vue: prefer `<script setup lang="ts">`.
|
|
||||||
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
|
|
||||||
- Remove dead code rather than commenting it out.
|
|
||||||
- Add tests for new behavior and regression tests for bug fixes.
|
|
||||||
|
|
||||||
## Pull requests
|
- No `unwrap()` or `expect()` in production code — use `?` operator
|
||||||
|
- `thiserror` for library errors, `anyhow` for application errors
|
||||||
|
- `tracing` for structured logging — never `println!`
|
||||||
|
- Run `cargo clippy` and `cargo fmt` before commits
|
||||||
|
|
||||||
1. Open one focused PR per behavior or documentation change.
|
### General
|
||||||
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.
|
|
||||||
|
|
||||||
Suggested commit format:
|
- Functions under 50 lines, single responsibility
|
||||||
|
- Comment WHY not WHAT
|
||||||
|
- Remove dead code — never comment it out
|
||||||
|
- No `TODO`/`FIXME` in commits
|
||||||
|
|
||||||
```text
|
## Commit Format
|
||||||
feat: add backup scheduling
|
|
||||||
fix: reject unsafe manifest volume
|
```
|
||||||
docs: clarify app deployment flow
|
type: description
|
||||||
test: cover catalog drift check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reporting bugs
|
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||||
|
|
||||||
Include:
|
Examples:
|
||||||
|
- `feat: add backup scheduling to settings page`
|
||||||
|
- `fix: handle WiFi connection timeout gracefully`
|
||||||
|
- `test: add unit tests for RPC client retry logic`
|
||||||
|
|
||||||
- exact version or commit;
|
## Pull Request Process
|
||||||
- host platform and architecture;
|
|
||||||
- steps to reproduce;
|
|
||||||
- expected and actual behavior;
|
|
||||||
- logs from the relevant component;
|
|
||||||
- screenshots for UI issues.
|
|
||||||
|
|
||||||
## Security
|
1. Ensure your branch is up to date with `main`
|
||||||
|
2. All checks must pass: TypeScript, build, tests, clippy
|
||||||
|
3. Include a clear description of what changed and why
|
||||||
|
4. Link any related issues
|
||||||
|
5. Request review from a maintainer
|
||||||
|
|
||||||
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
|
- [ ] Frontend builds (`npm run build`)
|
||||||
|
- [ ] Tests pass (`npm test`)
|
||||||
|
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
|
||||||
|
- [ ] No new compiler warnings
|
||||||
|
- [ ] Follows code style guidelines above
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
- New features need tests
|
||||||
|
- Bug fixes need a regression test
|
||||||
|
- Frontend: Vitest + Vue Test Utils
|
||||||
|
- Backend: `#[test]` and `#[tokio::test]`
|
||||||
|
- Target: maintain or improve existing coverage
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Use the **Bug Report** issue template. Include:
|
||||||
|
|
||||||
|
1. Steps to reproduce
|
||||||
|
2. Expected behavior
|
||||||
|
3. Actual behavior
|
||||||
|
4. System info (hardware, OS version, Archipelago version)
|
||||||
|
5. Screenshots if applicable
|
||||||
|
6. Relevant logs (`journalctl -u archipelago`)
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
Use the **Feature Request** issue template. Include:
|
||||||
|
|
||||||
|
1. Problem description
|
||||||
|
2. Proposed solution
|
||||||
|
3. Alternatives considered
|
||||||
|
4. Impact on existing users
|
||||||
|
|
||||||
|
## App Submissions
|
||||||
|
|
||||||
|
To submit an app for the Archipelago marketplace:
|
||||||
|
|
||||||
|
1. Create a manifest following `docs/app-manifest-spec.md`
|
||||||
|
2. Ensure the container image is published to a public registry
|
||||||
|
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
|
||||||
|
4. Open a PR adding the app to the curated list
|
||||||
|
5. Include: app description, icon, resource requirements, dependencies
|
||||||
|
|
||||||
|
### App Requirements
|
||||||
|
|
||||||
|
- Container must run as non-root (UID > 1000)
|
||||||
|
- `readonly_root: true` unless explicitly justified
|
||||||
|
- Drop all capabilities except those required
|
||||||
|
- `no-new-privileges: true`
|
||||||
|
- Pin specific image versions (no `latest` tag)
|
||||||
|
- No hardcoded secrets
|
||||||
|
|
||||||
|
## Security Disclosure
|
||||||
|
|
||||||
|
**Do NOT open public issues for security vulnerabilities.**
|
||||||
|
|
||||||
|
Email security concerns to the maintainers directly. Include:
|
||||||
|
|
||||||
|
1. Description of the vulnerability
|
||||||
|
2. Steps to reproduce
|
||||||
|
3. Potential impact
|
||||||
|
4. Suggested fix (if any)
|
||||||
|
|
||||||
|
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contribution is licensed under the
|
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||||
project's MIT License.
|
|
||||||
|
|||||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Dorian and the Archipelago Project contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
73
NOTICE
73
NOTICE
@ -1,73 +0,0 @@
|
|||||||
# Archipelago — Third-Party Notices
|
|
||||||
|
|
||||||
Archipelago is licensed under the MIT License (see LICENSE).
|
|
||||||
This file lists third-party components included in this repository and its
|
|
||||||
release artifacts, with their licenses and required attributions.
|
|
||||||
|
|
||||||
## Embedded / vendored components
|
|
||||||
|
|
||||||
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
|
|
||||||
Copyright (c) 2026 Johnathan Corgan. MIT License.
|
|
||||||
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
|
|
||||||
compiled into the Android companion app (`Android/rust/archy-fips-core`).
|
|
||||||
|
|
||||||
- **QR Code Generator for JavaScript** — http://www.d-project.com/
|
|
||||||
Copyright (c) 2009 Kazuhiko Arase. MIT License.
|
|
||||||
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
|
|
||||||
(original headers preserved).
|
|
||||||
|
|
||||||
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
|
|
||||||
Binary extracted into the OS image at `/opt/archipelago/bin/`.
|
|
||||||
|
|
||||||
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
|
|
||||||
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
|
|
||||||
license with field-of-use restrictions: no use in systems designed to harm
|
|
||||||
human beings, and no use in AI/ML training datasets). The optional
|
|
||||||
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
|
|
||||||
Reticulum License is NOT an OSI-approved open-source license; it applies
|
|
||||||
only to that optional component, not to Archipelago itself.
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
- **Montserrat** — SIL Open Font License 1.1
|
|
||||||
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
|
|
||||||
- **Open Sans** — Apache License 2.0
|
|
||||||
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
|
|
||||||
|
|
||||||
## Artwork and icons
|
|
||||||
|
|
||||||
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
|
|
||||||
device illustrations from the Meshtastic project — https://meshtastic.org
|
|
||||||
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
|
|
||||||
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
|
|
||||||
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
|
|
||||||
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
|
|
||||||
(MIT, https://github.com/halfmage/pixelarticons).
|
|
||||||
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
|
|
||||||
and `service-icons/` are trademarks of their respective owners, used solely
|
|
||||||
to identify the corresponding applications. No endorsement is implied.
|
|
||||||
|
|
||||||
## Original media
|
|
||||||
|
|
||||||
All demo content (music, photos, posters in `demo/`), UI sound effects,
|
|
||||||
background images, and intro video in `neode-ui/public/assets/` are original
|
|
||||||
works created and owned by the Archipelago project author, released with the
|
|
||||||
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
|
|
||||||
ElevenLabs TTS under a commercial-use plan.
|
|
||||||
|
|
||||||
## Redistributed software (ISO and container registry)
|
|
||||||
|
|
||||||
The Archipelago OS image is based on Debian and redistributes Debian packages
|
|
||||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
|
||||||
required for hardware support); per-package license texts are preserved at
|
|
||||||
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
|
|
||||||
is available via Debian (https://snapshot.debian.org) as referenced in each
|
|
||||||
release's notes. Container images offered through the app catalog and mirror
|
|
||||||
registry remain under their upstream licenses (including GPL/AGPL software
|
|
||||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
|
||||||
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
|
|
||||||
the app catalog. The modified mempool-frontend image is built from
|
|
||||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
|
||||||
|
|
||||||
Full per-crate and per-package license inventories for release binaries are
|
|
||||||
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).
|
|
||||||
229
README.md
229
README.md
@ -1,11 +1,8 @@
|
|||||||
# Archipelago
|
# Archipelago
|
||||||
|
|
||||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
> Self-Sovereign Bitcoin Node OS
|
||||||
|
|
||||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
|
||||||
self-hosted apps, mesh communication, decentralized identity, and federation.
|
|
||||||
Apps are packaged as declarative `manifest.yml` files and run as rootless
|
|
||||||
Podman containers managed by the Rust backend.
|
|
||||||
|
|
||||||
[](https://www.debian.org/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@ -13,101 +10,193 @@ Podman containers managed by the Rust backend.
|
|||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## What is here
|
## Philosophy
|
||||||
|
|
||||||
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
|
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
|
||||||
helpers, and performance/resource management.
|
|
||||||
- `neode-ui/` - Vue 3 + TypeScript frontend.
|
|
||||||
- `apps/` - app manifests and custom app container sources.
|
|
||||||
- `docker/` - supporting container build contexts for UI companion surfaces.
|
|
||||||
- `image-recipe/` - bootable image/ISO build inputs.
|
|
||||||
- `Android/` - Android companion app.
|
|
||||||
- `scripts/` - development, release, deployment, and validation tooling.
|
|
||||||
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
|
|
||||||
|
|
||||||
## Platform model
|
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
|
||||||
|
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
|
||||||
|
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
|
||||||
|
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
|
||||||
|
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
|
||||||
|
|
||||||
Archipelago is built as a developer-ready app platform, not a fixed appliance:
|
## Features
|
||||||
|
|
||||||
- Apps are declared in `apps/<app-id>/manifest.yml`.
|
### Bitcoin Infrastructure
|
||||||
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
|
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
|
||||||
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
|
- **LND** and **Core Lightning** with channel management
|
||||||
- App data lives under `/var/lib/archipelago/<app-id>/`.
|
- **ElectrumX** Electrum server for wallet connectivity
|
||||||
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
|
- **BTCPay Server** for accepting Bitcoin payments
|
||||||
injected through Podman secrets rather than static environment values.
|
- **Mempool** block explorer and fee estimator
|
||||||
- Release and app catalogs are signed and verified against a pinned trust
|
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
|
||||||
anchor.
|
|
||||||
|
|
||||||
Start with:
|
### Self-Hosted Apps (50+)
|
||||||
|
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
|
||||||
|
|
||||||
- [Architecture](docs/architecture.md)
|
### Mesh Networking (tri-protocol)
|
||||||
- [Developer Guide](docs/developer-guide.md)
|
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
|
||||||
- [App Developer Guide](docs/app-developer-guide.md)
|
- End-to-end encryption with X3DH key agreement + double-ratchet
|
||||||
- [App Manifest Spec](docs/app-manifest-spec.md)
|
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
|
||||||
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
|
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
|
||||||
- [Operations Runbook](docs/operations-runbook.md)
|
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
|
||||||
|
|
||||||
## Quick start
|
### Decentralized Identity
|
||||||
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
|
- Multi-identity management (Personal/Business/Anonymous)
|
||||||
|
- W3C Verifiable Credentials issuance and verification
|
||||||
|
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
|
||||||
|
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
|
||||||
|
|
||||||
### Frontend
|
### Multi-Node Federation
|
||||||
|
- Invite-based node joining over Tor hidden services
|
||||||
|
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||||
|
- State sync and app deployment across federated nodes
|
||||||
|
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
|
||||||
|
|
||||||
|
### System Updates
|
||||||
|
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
|
||||||
|
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
|
||||||
|
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
|
||||||
|
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
|
||||||
|
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
|
||||||
|
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
|
||||||
|
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
|
||||||
|
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
**Done**
|
||||||
|
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
|
||||||
|
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
|
||||||
|
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
|
||||||
|
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
|
||||||
|
|
||||||
|
**In progress**
|
||||||
|
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
|
||||||
|
- Quadlet default flip fleet-wide + container-flapping elimination
|
||||||
|
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
|
||||||
|
|
||||||
|
**Planned**
|
||||||
|
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
|
||||||
|
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
|
||||||
|
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
|
||||||
|
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
|
||||||
|
|
||||||
|
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Install from ISO
|
||||||
|
|
||||||
|
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
|
||||||
|
2. Flash to USB drive with Balena Etcher or `dd`
|
||||||
|
3. Boot from USB on target hardware and follow the automated installer
|
||||||
|
4. Access the web UI at `http://<device-ip>`
|
||||||
|
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
|
||||||
|
|
||||||
|
### Supported Hardware
|
||||||
|
|
||||||
|
| Platform | Examples | Minimum |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
|
||||||
|
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
|
||||||
|
|
||||||
|
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- macOS or Linux for frontend development
|
||||||
|
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
|
||||||
|
- Node.js 20+, Rust stable toolchain
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
||||||
|
npm run type-check # TypeScript validation
|
||||||
|
npm run build # Production build → web/dist/neode-ui/
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
|
### Backend Development
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core
|
cd core # Rust workspace root (no Cargo.toml at repo root)
|
||||||
cargo build
|
cargo build
|
||||||
cargo test --all-features
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux is the supported backend runtime and release-build target. macOS is fine
|
### Deploy to a Test Node
|
||||||
for frontend work and many Rust compile/test loops, but host integration tests
|
|
||||||
that touch Podman, systemd, networking, or image build paths require Linux.
|
|
||||||
|
|
||||||
### App manifests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
|
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||||
python3 scripts/generate-app-catalog.py
|
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
|
### Release (tarball-only)
|
||||||
|
|
||||||
## Documentation map
|
Releases ship as a backend binary and a frontend tarball referenced by
|
||||||
|
`releases/manifest.json`, published to the self-hosted Gitea release server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/create-release.sh 1.2.3
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Debian 13 (Trixie)
|
||||||
|
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
|
||||||
|
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||||
|
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
|
||||||
|
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
|
||||||
|
│ │ federation, wallet, updates, marketplace
|
||||||
|
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
|
||||||
|
│ │ health monitor, signed app catalog
|
||||||
|
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
|
||||||
|
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
|
||||||
|
│ └── core/performance/ — resource limits
|
||||||
|
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
|
||||||
|
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
|
||||||
|
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
|
||||||
|
└── System Tor (hidden services, SOCKS5 proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
| Doc | Purpose |
|
| Doc | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
|
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
|
||||||
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
|
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||||
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
|
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
|
||||||
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
|
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||||
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
|
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
||||||
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
|
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||||
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||||
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||||
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
||||||
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
|
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
||||||
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
|
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
||||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
|
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
||||||
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
|
1. Fork the repository
|
||||||
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
|
2. Create a feature branch (`feature/description`)
|
||||||
issue.
|
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
|
||||||
|
4. Submit a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
|
[MIT License](LICENSE)
|
||||||
are listed in [NOTICE](NOTICE) and generated license inventories in component
|
|
||||||
release artifacts.
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
|
||||||
|
|||||||
38
SECURITY.md
38
SECURITY.md
@ -1,38 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting vulnerabilities
|
|
||||||
|
|
||||||
Please do not open a public issue for a security vulnerability.
|
|
||||||
|
|
||||||
Until a dedicated security intake address is published, report privately to the
|
|
||||||
project maintainer through the repository owner account or the private contact
|
|
||||||
channel listed on the project homepage.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- affected commit, version, or release;
|
|
||||||
- affected component;
|
|
||||||
- reproduction steps;
|
|
||||||
- expected impact;
|
|
||||||
- logs, proof of concept, or packet captures when relevant;
|
|
||||||
- whether the issue is already public.
|
|
||||||
|
|
||||||
We aim to acknowledge credible reports within 48 hours and coordinate fixes
|
|
||||||
before public disclosure.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Security-sensitive areas include:
|
|
||||||
|
|
||||||
- authentication, session handling, CSRF, and rate limiting;
|
|
||||||
- release and app-catalog signature verification;
|
|
||||||
- container manifest validation and runtime compilation;
|
|
||||||
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
|
|
||||||
- backup encryption and key derivation;
|
|
||||||
- federation, Tor, Nostr, mesh, DID, and credential flows;
|
|
||||||
- Android companion pairing and device-token handling.
|
|
||||||
|
|
||||||
## Supported versions
|
|
||||||
|
|
||||||
Archipelago is currently pre-1.0 alpha software. Security fixes target the
|
|
||||||
current `main` branch and the latest published alpha release.
|
|
||||||
@ -76,17 +76,7 @@ podman run -p 18084:8080 \
|
|||||||
|
|
||||||
## Integration Checklist
|
## Integration Checklist
|
||||||
|
|
||||||
Adding a new app requires updates in multiple places:
|
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||||
|
|
||||||
- add `apps/<app-id>/manifest.yml`;
|
|
||||||
- add a Dockerfile and source directory only when the app is built locally;
|
|
||||||
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
|
|
||||||
- declare `interfaces.main` for user-facing web UIs;
|
|
||||||
- declare generated secrets instead of hardcoding credentials;
|
|
||||||
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
|
|
||||||
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
|
|
||||||
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
|
|
||||||
- test install, launch, stop, start, restart, uninstall, and reinstall.
|
|
||||||
|
|
||||||
## Port Assignments
|
## Port Assignments
|
||||||
|
|
||||||
|
|||||||
33
core/.env.production
Normal file
33
core/.env.production
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Archipelago Production Configuration
|
||||||
|
# This file is bundled with the macOS app
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
ARCHIPELAGO_HOST=127.0.0.1
|
||||||
|
ARCHIPELAGO_PORT=8100
|
||||||
|
ARCHIPELAGO_BACKEND_PORT=3030
|
||||||
|
|
||||||
|
# Data Directories (relative to ~/Library/Application Support/Archipelago)
|
||||||
|
ARCHIPELAGO_DATA_DIR=data
|
||||||
|
ARCHIPELAGO_LOG_DIR=logs
|
||||||
|
|
||||||
|
# Frontend Configuration
|
||||||
|
ARCHIPELAGO_FRONTEND_DIR=frontend
|
||||||
|
|
||||||
|
# Docker UI Configuration
|
||||||
|
ARCHIPELAGO_DOCKER_UI_DIR=docker-ui
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# Production Mode
|
||||||
|
NODE_ENV=production
|
||||||
|
ARCHIPELAGO_MODE=production
|
||||||
|
|
||||||
|
# Docker Configuration
|
||||||
|
DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
|
|
||||||
|
# Disable External API Calls in Production
|
||||||
|
ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true
|
||||||
52
core/Cargo.lock
generated
52
core/Cargo.lock
generated
@ -84,15 +84,6 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arbitrary"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
|
||||||
dependencies = [
|
|
||||||
"derive_arbitrary",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@ -104,7 +95,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.118-alpha"
|
version = "1.7.115-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
@ -170,7 +161,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"zbase32",
|
"zbase32",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zip",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1185,17 +1175,6 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_arbitrary"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.114",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@ -6916,37 +6895,8 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zip"
|
|
||||||
version = "2.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
|
||||||
dependencies = [
|
|
||||||
"arbitrary",
|
|
||||||
"crc32fast",
|
|
||||||
"crossbeam-utils",
|
|
||||||
"displaydoc",
|
|
||||||
"flate2",
|
|
||||||
"indexmap",
|
|
||||||
"memchr",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"zopfli",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.16"
|
version = "1.0.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zopfli"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"crc32fast",
|
|
||||||
"log",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|||||||
@ -1,656 +0,0 @@
|
|||||||
# Third-Party Rust Crate Licenses — Archipelago core
|
|
||||||
|
|
||||||
Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates.
|
|
||||||
Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
|
||||||
|
|
||||||
| Crate | Version | License | Source |
|
|
||||||
|---|---|---|---|
|
|
||||||
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 |
|
|
||||||
| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
|
||||||
| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs |
|
|
||||||
| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash |
|
|
||||||
| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick |
|
|
||||||
| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 |
|
|
||||||
| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties |
|
|
||||||
| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow |
|
|
||||||
| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap |
|
|
||||||
| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 |
|
|
||||||
| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref |
|
|
||||||
| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec |
|
|
||||||
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait |
|
|
||||||
| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git |
|
|
||||||
| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git |
|
|
||||||
| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream |
|
|
||||||
| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git |
|
|
||||||
| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill |
|
|
||||||
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker |
|
|
||||||
| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc |
|
|
||||||
| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg |
|
|
||||||
| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon |
|
|
||||||
| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree |
|
|
||||||
| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 |
|
|
||||||
| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
|
||||||
| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
|
||||||
| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt |
|
|
||||||
| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 |
|
|
||||||
| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge |
|
|
||||||
| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ |
|
|
||||||
| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec |
|
|
||||||
| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags |
|
|
||||||
| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags |
|
|
||||||
| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 |
|
|
||||||
| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
|
||||||
| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs |
|
|
||||||
| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo |
|
|
||||||
| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck |
|
|
||||||
| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder |
|
|
||||||
| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite |
|
|
||||||
| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes |
|
|
||||||
| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
|
||||||
| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
|
||||||
| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs |
|
|
||||||
| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if |
|
|
||||||
| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases |
|
|
||||||
| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 |
|
|
||||||
| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono |
|
|
||||||
| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs |
|
|
||||||
| combine | 4.6.7 | MIT | https://github.com/Marwes/combine |
|
|
||||||
| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid |
|
|
||||||
| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
|
||||||
| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
|
||||||
| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case |
|
|
||||||
| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium |
|
|
||||||
| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git |
|
|
||||||
| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git |
|
|
||||||
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast |
|
|
||||||
| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section |
|
|
||||||
| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy |
|
|
||||||
| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
|
||||||
| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
|
||||||
| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
|
||||||
| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek |
|
|
||||||
| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der |
|
|
||||||
| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git |
|
|
||||||
| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged |
|
|
||||||
| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
|
||||||
| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
|
||||||
| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker |
|
|
||||||
| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc |
|
|
||||||
| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 |
|
|
||||||
| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 |
|
|
||||||
| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures |
|
|
||||||
| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
|
||||||
| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
|
||||||
| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either |
|
|
||||||
| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io |
|
|
||||||
| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal |
|
|
||||||
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs |
|
|
||||||
| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc |
|
|
||||||
| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger |
|
|
||||||
| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent |
|
|
||||||
| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno |
|
|
||||||
| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ |
|
|
||||||
| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand |
|
|
||||||
| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
|
||||||
| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
|
||||||
| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime |
|
|
||||||
| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
|
||||||
| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs |
|
|
||||||
| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume |
|
|
||||||
| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv |
|
|
||||||
| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash |
|
|
||||||
| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash |
|
|
||||||
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
|
||||||
| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered |
|
|
||||||
| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite |
|
|
||||||
| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git |
|
|
||||||
| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git |
|
|
||||||
| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers |
|
|
||||||
| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 |
|
|
||||||
| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 |
|
|
||||||
| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs |
|
|
||||||
| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 |
|
|
||||||
| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless |
|
|
||||||
| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck |
|
|
||||||
| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs |
|
|
||||||
| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex |
|
|
||||||
| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
|
||||||
| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
|
||||||
| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit |
|
|
||||||
| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ |
|
|
||||||
| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs |
|
|
||||||
| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
|
||||||
| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
|
||||||
| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse |
|
|
||||||
| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate |
|
|
||||||
| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime |
|
|
||||||
| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array |
|
|
||||||
| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper |
|
|
||||||
| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper |
|
|
||||||
| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
|
||||||
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
|
||||||
| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util |
|
|
||||||
| hyper-ws-listener | 0.3.0 | MIT | |
|
|
||||||
| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
|
||||||
| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
|
||||||
| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena |
|
|
||||||
| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case |
|
|
||||||
| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash |
|
|
||||||
| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
|
||||||
| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter |
|
|
||||||
| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs |
|
|
||||||
| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd |
|
|
||||||
| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image |
|
|
||||||
| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap |
|
|
||||||
| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder |
|
|
||||||
| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant |
|
|
||||||
| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig |
|
|
||||||
| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet |
|
|
||||||
| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string |
|
|
||||||
| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs |
|
|
||||||
| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
|
||||||
| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
|
||||||
| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets |
|
|
||||||
| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util |
|
|
||||||
| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
|
||||||
| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
|
||||||
| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal |
|
|
||||||
| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa |
|
|
||||||
| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys |
|
|
||||||
| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs |
|
|
||||||
| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt |
|
|
||||||
| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc |
|
|
||||||
| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins |
|
|
||||||
| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git |
|
|
||||||
| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
|
||||||
| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys |
|
|
||||||
| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys |
|
|
||||||
| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log |
|
|
||||||
| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom |
|
|
||||||
| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab |
|
|
||||||
| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr |
|
|
||||||
| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline |
|
|
||||||
| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers |
|
|
||||||
| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd |
|
|
||||||
| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr |
|
|
||||||
| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime |
|
|
||||||
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical |
|
|
||||||
| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide |
|
|
||||||
| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio |
|
|
||||||
| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka |
|
|
||||||
| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git |
|
|
||||||
| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
|
||||||
| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
|
||||||
| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future |
|
|
||||||
| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher |
|
|
||||||
| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs |
|
|
||||||
| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git |
|
|
||||||
| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils |
|
|
||||||
| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev |
|
|
||||||
| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core |
|
|
||||||
| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
|
||||||
| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
|
||||||
| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto |
|
|
||||||
| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys |
|
|
||||||
| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
|
||||||
| nom | 7.1.3 | MIT | https://github.com/Geal/nom |
|
|
||||||
| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term |
|
|
||||||
| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint |
|
|
||||||
| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv |
|
|
||||||
| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer |
|
|
||||||
| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits |
|
|
||||||
| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
|
||||||
| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
|
||||||
| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads |
|
|
||||||
| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git |
|
|
||||||
| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell |
|
|
||||||
| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe |
|
|
||||||
| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl |
|
|
||||||
| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya |
|
|
||||||
| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking |
|
|
||||||
| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash |
|
|
||||||
| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste |
|
|
||||||
| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 |
|
|
||||||
| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git |
|
|
||||||
| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
|
||||||
| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos |
|
|
||||||
| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
|
||||||
| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
|
||||||
| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite |
|
|
||||||
| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils |
|
|
||||||
| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 |
|
|
||||||
| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs |
|
|
||||||
| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain |
|
|
||||||
| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ |
|
|
||||||
| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic |
|
|
||||||
| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
|
||||||
| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io |
|
|
||||||
| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
|
||||||
| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
|
||||||
| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt |
|
|
||||||
| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion |
|
|
||||||
| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie |
|
|
||||||
| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease |
|
|
||||||
| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate |
|
|
||||||
| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
|
||||||
| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
|
||||||
| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack |
|
|
||||||
| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 |
|
|
||||||
| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm |
|
|
||||||
| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust |
|
|
||||||
| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml |
|
|
||||||
| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote |
|
|
||||||
| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
|
||||||
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
|
||||||
| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core |
|
|
||||||
| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs |
|
|
||||||
| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections |
|
|
||||||
| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen |
|
|
||||||
| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb |
|
|
||||||
| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure |
|
|
||||||
| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
|
||||||
| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
|
||||||
| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy |
|
|
||||||
| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
|
||||||
| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
|
||||||
| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf |
|
|
||||||
| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring |
|
|
||||||
| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash |
|
|
||||||
| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs |
|
|
||||||
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git |
|
|
||||||
| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix |
|
|
||||||
| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
|
||||||
| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
|
||||||
| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs |
|
|
||||||
| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile |
|
|
||||||
| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types |
|
|
||||||
| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
|
||||||
| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
|
||||||
| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki |
|
|
||||||
| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki |
|
|
||||||
| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion |
|
|
||||||
| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu |
|
|
||||||
| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file |
|
|
||||||
| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs |
|
|
||||||
| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls |
|
|
||||||
| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard |
|
|
||||||
| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt |
|
|
||||||
| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs |
|
|
||||||
| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify |
|
|
||||||
| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
|
||||||
| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
|
||||||
| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
|
||||||
| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
|
||||||
| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize |
|
|
||||||
| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell |
|
|
||||||
| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver |
|
|
||||||
| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper |
|
|
||||||
| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode |
|
|
||||||
| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes |
|
|
||||||
| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json |
|
|
||||||
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded |
|
|
||||||
| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml |
|
|
||||||
| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs |
|
|
||||||
| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs |
|
|
||||||
| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol |
|
|
||||||
| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab |
|
|
||||||
| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex |
|
|
||||||
| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook |
|
|
||||||
| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature |
|
|
||||||
| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits |
|
|
||||||
| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 |
|
|
||||||
| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 |
|
|
||||||
| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 |
|
|
||||||
| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns |
|
|
||||||
| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash |
|
|
||||||
| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab |
|
|
||||||
| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec |
|
|
||||||
| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo |
|
|
||||||
| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
|
||||||
| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
|
||||||
| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez |
|
|
||||||
| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git |
|
|
||||||
| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git |
|
|
||||||
| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki |
|
|
||||||
| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
|
||||||
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait |
|
|
||||||
| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs |
|
|
||||||
| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
|
||||||
| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
|
||||||
| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle |
|
|
||||||
| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
|
||||||
| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
|
||||||
| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid |
|
|
||||||
| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
|
||||||
| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
|
||||||
| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure |
|
|
||||||
| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git |
|
|
||||||
| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs |
|
|
||||||
| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile |
|
|
||||||
| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor |
|
|
||||||
| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs |
|
|
||||||
| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec |
|
|
||||||
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros |
|
|
||||||
| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
|
||||||
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
|
||||||
| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks |
|
|
||||||
| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite |
|
|
||||||
| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite |
|
|
||||||
| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ |
|
|
||||||
| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs |
|
|
||||||
| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http |
|
|
||||||
| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock |
|
|
||||||
| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
|
||||||
| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
|
||||||
| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum |
|
|
||||||
| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident |
|
|
||||||
| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization |
|
|
||||||
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation |
|
|
||||||
| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid |
|
|
||||||
| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml |
|
|
||||||
| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted |
|
|
||||||
| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
|
||||||
| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding |
|
|
||||||
| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 |
|
|
||||||
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter |
|
|
||||||
| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid |
|
|
||||||
| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable |
|
|
||||||
| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs |
|
|
||||||
| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check |
|
|
||||||
| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir |
|
|
||||||
| want | 0.3.1 | MIT | https://github.com/seanmonstar/want |
|
|
||||||
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi |
|
|
||||||
| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
|
||||||
| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
|
||||||
| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen |
|
|
||||||
| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures |
|
|
||||||
| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro |
|
|
||||||
| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support |
|
|
||||||
| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared |
|
|
||||||
| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder |
|
|
||||||
| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata |
|
|
||||||
| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
|
||||||
| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
|
||||||
| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser |
|
|
||||||
| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys |
|
|
||||||
| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time |
|
|
||||||
| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs |
|
|
||||||
| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util |
|
|
||||||
| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow |
|
|
||||||
| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow |
|
|
||||||
| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs |
|
|
||||||
| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component |
|
|
||||||
| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser |
|
|
||||||
| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs |
|
|
||||||
| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm |
|
|
||||||
| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git |
|
|
||||||
| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr |
|
|
||||||
| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs |
|
|
||||||
| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs |
|
|
||||||
| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs |
|
|
||||||
| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust |
|
|
||||||
| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
|
||||||
| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
|
||||||
| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij |
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.118-alpha"
|
version = "1.7.115-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
@ -108,10 +108,6 @@ bytes = "1"
|
|||||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||||
serial2-tokio = "0.1"
|
serial2-tokio = "0.1"
|
||||||
|
|
||||||
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
|
|
||||||
# per-platform release zip (see mesh/flash.rs).
|
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
|
||||||
|
|
||||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||||
hkdf = "0.12.4"
|
hkdf = "0.12.4"
|
||||||
|
|
||||||
|
|||||||
@ -465,7 +465,6 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&peer.pubkey),
|
Some(&peer.pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -279,7 +279,6 @@ impl RpcHandler {
|
|||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.fips_timeout(std::time::Duration::from_secs(8))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -365,11 +364,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
// The Cloud page's hottest call: without a fast-fail cap a
|
|
||||||
// cold FIPS path burned ~16.6s before Tor even started,
|
|
||||||
// against the UI's 30s deadline — users saw errors, not
|
|
||||||
// fallback.
|
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -1143,7 +1137,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer for preview")?;
|
.context("Failed to connect to peer for preview")?;
|
||||||
|
|||||||
@ -132,7 +132,6 @@ impl RpcHandler {
|
|||||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
|
||||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||||
@ -391,12 +390,7 @@ impl RpcHandler {
|
|||||||
// Mesh networking (Meshcore LoRa)
|
// Mesh networking (Meshcore LoRa)
|
||||||
"mesh.status" => self.handle_mesh_status().await,
|
"mesh.status" => self.handle_mesh_status().await,
|
||||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||||
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
|
|
||||||
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
|
|
||||||
"mesh.flash-status" => self.handle_mesh_flash_status().await,
|
|
||||||
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
|
|
||||||
"mesh.peers" => self.handle_mesh_peers().await,
|
"mesh.peers" => self.handle_mesh_peers().await,
|
||||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
|
||||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||||
"mesh.send" => self.handle_mesh_send(params).await,
|
"mesh.send" => self.handle_mesh_send(params).await,
|
||||||
|
|||||||
@ -865,9 +865,7 @@ impl RpcHandler {
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(&self.config.data_dir);
|
|
||||||
|
|
||||||
match req.send_json(&body).await {
|
match req.send_json(&body).await {
|
||||||
Ok((resp, transport)) if resp.status().is_success() => {
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
|
|||||||
@ -13,14 +13,7 @@ use anyhow::Result;
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
let mut v = serde_json::to_value(status)?;
|
Ok(serde_json::to_value(status)?)
|
||||||
// Dial outcome counters (process-lifetime): how often peer dials
|
|
||||||
// used FIPS vs fell back to Tor, broken down by reason. This is
|
|
||||||
// the observability that makes "FIPS uptime" measurable.
|
|
||||||
if let Some(obj) = v.as_object_mut() {
|
|
||||||
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the companion app needs to join this node's mesh, embedded
|
/// Everything the companion app needs to join this node's mesh, embedded
|
||||||
@ -90,8 +83,7 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
fips::config::install(&identity_dir).await?;
|
fips::config::install(&identity_dir).await?;
|
||||||
let unit = fips::service::activation_unit().await;
|
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||||
fips::service::activate(unit).await?;
|
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
Ok(serde_json::to_value(status)?)
|
Ok(serde_json::to_value(status)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,33 +36,6 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
// Decode the invoice up front (fast, local) so we know its payment
|
|
||||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
|
||||||
// below, the hash is what lets the UI keep tracking it instead of
|
|
||||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
|
||||||
// block the payment itself.
|
|
||||||
let (decoded_hash, decoded_amt) = match client
|
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
|
||||||
Ok(d) => (
|
|
||||||
d.get("payment_hash")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
d.get("num_satoshis")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
|
||||||
.unwrap_or(0),
|
|
||||||
),
|
|
||||||
Err(_) => (String::new(), 0),
|
|
||||||
},
|
|
||||||
Err(_) => (String::new(), 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pay_body = serde_json::json!({
|
let mut pay_body = serde_json::json!({
|
||||||
"payment_request": payment_request,
|
"payment_request": payment_request,
|
||||||
});
|
});
|
||||||
@ -70,50 +43,13 @@ impl RpcHandler {
|
|||||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
let resp = client
|
||||||
// payment settles or definitively fails, and multi-hop routing with
|
|
||||||
// retries routinely takes longer than the shared client's 15s budget.
|
|
||||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
|
||||||
// paying in the background — only LND may declare a payment failed,
|
|
||||||
// so a post-connect timeout is IN FLIGHT (status: pending), never
|
|
||||||
// failure. The window is deliberately SHORT: most payments settle in
|
|
||||||
// a couple of seconds and still get their answer in one round trip,
|
|
||||||
// while a slow multi-hop route flips the UI into its "settling…"
|
|
||||||
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
|
|
||||||
// freezing the modal for two minutes with no feedback (framework-pt
|
|
||||||
// user report, 2026-07-29).
|
|
||||||
let pay_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()
|
|
||||||
.context("Failed to create HTTP client")?;
|
|
||||||
|
|
||||||
let resp = match pay_client
|
|
||||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.json(&pay_body)
|
.json(&pay_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
{
|
.context("Failed to pay invoice")?;
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) if e.is_connect() => {
|
|
||||||
// Never reached LND — nothing was sent; this IS a hard error.
|
|
||||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Timed out (or lost the connection) AFTER the payment was
|
|
||||||
// handed to LND — it may well still succeed. Report pending
|
|
||||||
// with the hash so the caller can poll lnd.paymentstatus.
|
|
||||||
info!("payinvoice wait elapsed; payment still in flight");
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"status": "pending",
|
|
||||||
"payment_hash": decoded_hash,
|
|
||||||
"amount_sats": decoded_amt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
@ -150,104 +86,20 @@ impl RpcHandler {
|
|||||||
.and_then(|r| r.get("total_amt"))
|
.and_then(|r| r.get("total_amt"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
.unwrap_or(decoded_amt);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let payment_hash = body
|
let payment_hash = body
|
||||||
.get("payment_hash")
|
.get("payment_hash")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.filter(|s| !s.is_empty())
|
.unwrap_or("")
|
||||||
.map(|s| s.to_string())
|
.to_string();
|
||||||
.unwrap_or(decoded_hash);
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"status": "succeeded",
|
|
||||||
"payment_hash": payment_hash,
|
"payment_hash": payment_hash,
|
||||||
"amount_sats": amount_sat,
|
"amount_sats": amount_sat,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
|
||||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
|
||||||
/// "pending"`) to a real terminal state instead of guessing.
|
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let payment_hash = params
|
|
||||||
.get("payment_hash")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
|
||||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
let resp = client
|
|
||||||
.get(format!(
|
|
||||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
|
||||||
))
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("LND REST connection failed")?;
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse payments response")?;
|
|
||||||
|
|
||||||
let hash_lower = payment_hash.to_lowercase();
|
|
||||||
let found = body
|
|
||||||
.get("payments")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.and_then(|arr| {
|
|
||||||
arr.iter().find(|p| {
|
|
||||||
p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let Some(p) = found else {
|
|
||||||
// Not in the latest window — either very old or LND never saw it.
|
|
||||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
|
||||||
};
|
|
||||||
|
|
||||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let status = match lnd_status {
|
|
||||||
"SUCCEEDED" => "succeeded",
|
|
||||||
"FAILED" => "failed",
|
|
||||||
_ => "in_flight",
|
|
||||||
};
|
|
||||||
let failure_reason = match p
|
|
||||||
.get("failure_reason")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
{
|
|
||||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
|
||||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
|
||||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
|
||||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
|
||||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
|
||||||
}
|
|
||||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
|
||||||
p.get(key)
|
|
||||||
.and_then(|f| f.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"status": status,
|
|
||||||
"failure_reason": failure_reason,
|
|
||||||
"amount_sats": amt(p, "value_sat"),
|
|
||||||
"fee_sats": amt(p, "fee_sat"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List on-chain transactions from LND.
|
/// List on-chain transactions from LND.
|
||||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
use super::super::RpcHandler;
|
|
||||||
use crate::mesh;
|
|
||||||
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
|
|
||||||
use crate::mesh::types::DeviceType;
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
fn parse_family(s: &str) -> Result<DeviceType> {
|
|
||||||
match s.trim().to_lowercase().as_str() {
|
|
||||||
"meshcore" => Ok(DeviceType::Meshcore),
|
|
||||||
"meshtastic" => Ok(DeviceType::Meshtastic),
|
|
||||||
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
|
||||||
other => anyhow::bail!(
|
|
||||||
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_board(s: &str) -> Result<FlashBoard> {
|
|
||||||
match s.trim().to_lowercase().as_str() {
|
|
||||||
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
|
|
||||||
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
|
|
||||||
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcHandler {
|
|
||||||
/// mesh.flash-list-firmware — resolve the available firmware version(s)
|
|
||||||
/// for a given family. v1 only ever surfaces "latest".
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let family = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("family"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
|
||||||
let family = parse_family(family)?;
|
|
||||||
let versions = flash::list_firmware(family).await?;
|
|
||||||
Ok(serde_json::json!({ "versions": versions }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
|
|
||||||
/// latest firmware for the given family, defaulting to a full chip
|
|
||||||
/// erase before write. `board` is optional: if the port's USB vid:pid
|
|
||||||
/// unambiguously resolves to a known board, that's used; otherwise the
|
|
||||||
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
|
|
||||||
/// doc comment on why we refuse to guess).
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let path = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("path"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
|
||||||
.to_string();
|
|
||||||
let family = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("family"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
|
||||||
let family = parse_family(family)?;
|
|
||||||
|
|
||||||
let detected = mesh::detect_devices().await;
|
|
||||||
anyhow::ensure!(
|
|
||||||
detected.iter().any(|d| d == &path),
|
|
||||||
"{path} is not a detected mesh-radio candidate port"
|
|
||||||
);
|
|
||||||
|
|
||||||
let board = match params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("board"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
Some(explicit) => parse_board(explicit)?,
|
|
||||||
None => {
|
|
||||||
let info = mesh::detect_devices_info()
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.find(|d| d.path == path);
|
|
||||||
info.as_ref()
|
|
||||||
.and_then(flash::resolve_flash_board)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Could not auto-detect the board on {path} — specify board explicitly"
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
flash::start_flash_job(
|
|
||||||
&self.flash_job,
|
|
||||||
&self.mesh_service_arc(),
|
|
||||||
self.config.data_dir.clone(),
|
|
||||||
path,
|
|
||||||
board,
|
|
||||||
family,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "started": true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.flash-status — poll the current (or most recent) flash job.
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
|
|
||||||
let job = self.flash_job.read().await;
|
|
||||||
match job.as_ref() {
|
|
||||||
Some(j) => {
|
|
||||||
let status: FlashJobStatus = j.snapshot().await;
|
|
||||||
let mut value = serde_json::to_value(&status)?;
|
|
||||||
if let Some(obj) = value.as_object_mut() {
|
|
||||||
obj.insert("active".into(), (!status.done).into());
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
None => Ok(serde_json::json!({ "active": false })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.flash-cancel — best-effort; only honored before erase/write has
|
|
||||||
/// started (see `FlashJob::cancel`'s doc comment).
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
|
|
||||||
let job = self.flash_job.read().await;
|
|
||||||
match job.as_ref() {
|
|
||||||
Some(j) => {
|
|
||||||
j.cancel().await?;
|
|
||||||
Ok(serde_json::json!({ "cancelled": true }))
|
|
||||||
}
|
|
||||||
None => anyhow::bail!("No flash job in progress"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
use super::super::RpcHandler;
|
use super::super::RpcHandler;
|
||||||
use crate::mesh;
|
use crate::mesh;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@ -132,14 +131,7 @@ impl RpcHandler {
|
|||||||
config.broadcast_identity = broadcast;
|
config.broadcast_identity = broadcast;
|
||||||
}
|
}
|
||||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||||
// Empty clears the custom mesh name (falls back to the server
|
config.advert_name = Some(name.to_string());
|
||||||
// name) — without this, a name could be set but never unset.
|
|
||||||
let trimmed = name.trim();
|
|
||||||
config.advert_name = if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(announce) = params
|
if let Some(announce) = params
|
||||||
.get("announce_block_headers")
|
.get("announce_block_headers")
|
||||||
@ -210,24 +202,10 @@ impl RpcHandler {
|
|||||||
|
|
||||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
// Apply to the running service in the background: configure() may
|
// If we have a running service, update its config
|
||||||
// stop+start the listener (config changes now restart the session so
|
let mut service = self.mesh_service.write().await;
|
||||||
// they actually take effect), and that can take seconds when the old
|
if let Some(svc) = service.as_mut() {
|
||||||
// session is mid-probe. Holding the service write-lock for that long
|
svc.configure(config.clone()).await?;
|
||||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
|
||||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
|
||||||
// already persisted above; the UI observes progress via mesh.status.
|
|
||||||
{
|
|
||||||
let service_arc = Arc::clone(&self.mesh_service);
|
|
||||||
let config_for_apply = config.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut service = service_arc.write().await;
|
|
||||||
if let Some(svc) = service.as_mut() {
|
|
||||||
if let Err(e) = svc.configure(config_for_apply).await {
|
|
||||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Mesh config updated");
|
info!("Mesh config updated");
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
mod assistant;
|
mod assistant;
|
||||||
mod bitcoin_ops;
|
mod bitcoin_ops;
|
||||||
mod flash;
|
|
||||||
mod messaging;
|
mod messaging;
|
||||||
mod safety;
|
mod safety;
|
||||||
mod status;
|
mod status;
|
||||||
|
|||||||
@ -101,59 +101,13 @@ impl RpcHandler {
|
|||||||
detected.iter().any(|d| d == &path),
|
detected.iter().any(|d| d == &path),
|
||||||
"{path} is not a detected mesh-radio candidate port"
|
"{path} is not a detected mesh-radio candidate port"
|
||||||
);
|
);
|
||||||
// Refuse to probe while a firmware flash is in flight. Confirmed
|
|
||||||
// live 2026-07-23: esptool ("multiple access on port?") and
|
|
||||||
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
|
|
||||||
// failed with symptoms consistent with a second process holding the
|
|
||||||
// same serial fd — the flash subprocess runs for minutes outside
|
|
||||||
// our own async runtime, so nothing previously stopped a concurrent
|
|
||||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
|
||||||
// from opening the identical port at the same time and corrupting
|
|
||||||
// both operations' handshakes.
|
|
||||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
|
||||||
anyhow::ensure!(
|
|
||||||
job.snapshot().await.done,
|
|
||||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Only hold the mesh_service lock long enough for the quick
|
|
||||||
// active-path guard check — NEVER across the actual probe, which
|
|
||||||
// can take 15-60s across its internal collision retries. Confirmed
|
|
||||||
// live 2026-07-23: holding this read lock for the full probe starved
|
|
||||||
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
|
||||||
// the write lock) well past its own bounded timeout, surfacing as
|
|
||||||
// "Mesh listener did not release the serial port" even though
|
|
||||||
// stop() itself was fast.
|
|
||||||
{
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
if let Some(svc) = service.as_ref() {
|
|
||||||
svc.ensure_probe_allowed(&path).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let probe = mesh::listener::probe_device(&path).await?;
|
|
||||||
Ok(serde_json::to_value(probe)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
|
||||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
|
||||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
|
||||||
/// the button only re-read server caches and never touched the radio.
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let Some(svc) = service.as_ref() else {
|
let probe = match service.as_ref() {
|
||||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
Some(svc) => svc.probe_device(&path).await?,
|
||||||
|
// No mesh service yet (radio never enabled) — probe directly.
|
||||||
|
None => mesh::listener::probe_device(&path).await?,
|
||||||
};
|
};
|
||||||
let status = svc.status().await;
|
Ok(serde_json::to_value(probe)?)
|
||||||
if status.device_connected {
|
|
||||||
let state = svc.shared_state();
|
|
||||||
let _ = state
|
|
||||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"refreshed": status.device_connected,
|
|
||||||
"device_connected": status.device_connected,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// mesh.peers — List discovered mesh peers.
|
/// mesh.peers — List discovered mesh peers.
|
||||||
|
|||||||
@ -575,17 +575,14 @@ impl RpcHandler {
|
|||||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let fed_node = nodes.iter().find(|n| {
|
let has_tor = peer_pubkey_hex
|
||||||
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|
.as_ref()
|
||||||
|| peer_did.as_deref() == Some(n.did.as_str())
|
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
|
||||||
});
|
.unwrap_or(false)
|
||||||
let has_tor = fed_node.is_some();
|
|| peer_did
|
||||||
// Distinct FIPS capability so the frontend can offer FIPS as its own
|
.as_ref()
|
||||||
// pill (not just a generic "Tor") — the dial layer still picks
|
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||||
// FIPS-first with Tor fallback on the actual send; these are honest
|
.unwrap_or(false);
|
||||||
// capability labels, with `last_transport` saying what worked last.
|
|
||||||
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
|
|
||||||
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
|
|
||||||
|
|
||||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||||
|
|
||||||
@ -621,8 +618,6 @@ impl RpcHandler {
|
|||||||
"tier": tier,
|
"tier": tier,
|
||||||
"est_seconds": est_seconds,
|
"est_seconds": est_seconds,
|
||||||
"has_tor": has_tor,
|
"has_tor": has_tor,
|
||||||
"has_fips": has_fips,
|
|
||||||
"last_transport": last_transport,
|
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"size": size,
|
"size": size,
|
||||||
"mesh_auto_max": MESH_AUTO_MAX,
|
"mesh_auto_max": MESH_AUTO_MAX,
|
||||||
@ -825,8 +820,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.fips_timeout(std::time::Duration::from_secs(8))
|
|
||||||
.record_transport(&self.config.data_dir)
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||||
|
|||||||
@ -89,9 +89,6 @@ pub struct RpcHandler {
|
|||||||
endpoint_rate_limiter: EndpointRateLimiter,
|
endpoint_rate_limiter: EndpointRateLimiter,
|
||||||
response_cache: ResponseCache,
|
response_cache: ResponseCache,
|
||||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||||
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
|
||||||
/// job at a time, since flashing needs exclusive access to the port.
|
|
||||||
flash_job: crate::mesh::flash::FlashJobHandle,
|
|
||||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||||
@ -163,7 +160,6 @@ impl RpcHandler {
|
|||||||
endpoint_rate_limiter,
|
endpoint_rate_limiter,
|
||||||
response_cache: ResponseCache::new(5),
|
response_cache: ResponseCache::new(5),
|
||||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
flash_job: crate::mesh::flash::new_job_handle(),
|
|
||||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
|||||||
@ -137,7 +137,6 @@ impl RpcHandler {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -226,7 +225,6 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&req.from_pubkey),
|
Some(&req.from_pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2324,28 +2324,17 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
async fn cleanup_stale_pasta_port(port: &str) {
|
||||||
// NEVER kill our own process. The daemon holds catalog app ports over
|
|
||||||
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
|
||||||
// terminate archipelago itself mid-install — installs failed and apps
|
|
||||||
// vanished on framework-pt 2026-07-27. Kill every listener on the port
|
|
||||||
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
|
||||||
let self_pid = std::process::id();
|
|
||||||
let kill_listener = format!(
|
let kill_listener = format!(
|
||||||
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||||
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
port
|
||||||
);
|
);
|
||||||
let _ = tokio::process::Command::new("sh")
|
let _ = tokio::process::Command::new("sh")
|
||||||
.args(["-c", &kill_listener])
|
.args(["-c", &kill_listener])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
let _ = tokio::process::Command::new("sudo")
|
||||||
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
.args(["fuser", "-k", &format!("{}/tcp", port)])
|
||||||
let fuser_kill = format!(
|
|
||||||
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &fuser_kill])
|
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@ -133,7 +133,6 @@ impl RpcHandler {
|
|||||||
Some(node_id.signing_key()),
|
Some(node_id.signing_key()),
|
||||||
recipient_pubkey.as_deref(),
|
recipient_pubkey.as_deref(),
|
||||||
node_name.as_deref(),
|
node_name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
|
|||||||
@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
|
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
|
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||||
/// the user-facing RPC returns immediately — the systemctl calls can take a few
|
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||||
/// seconds the first time on slow hardware. Any failure is logged but does not
|
/// a few seconds the first time on slow hardware. Any failure is logged but
|
||||||
/// break onboarding; the user can still hit fips.install manually from the
|
/// does not break onboarding; the user can still hit fips.install manually
|
||||||
/// dashboard as an escape hatch.
|
/// from the dashboard as an escape hatch.
|
||||||
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let identity_dir = data_dir.join("identity");
|
let identity_dir = data_dir.join("identity");
|
||||||
@ -78,12 +78,11 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
|||||||
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let unit = crate::fips::service::activation_unit().await;
|
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||||
if let Err(e) = crate::fips::service::activate(unit).await {
|
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
||||||
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
|
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,8 +138,8 @@ impl RpcHandler {
|
|||||||
// Initialize identity index at 0.
|
// Initialize identity index at 0.
|
||||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||||
|
|
||||||
// fips_key is now on disk — auto-activate FIPS so the user doesn't
|
// fips_key is now on disk — auto-activate archipelago-fips so the
|
||||||
// have to hit a manual Start button. Detached task;
|
// user doesn't have to hit an "Activate" button. Detached task;
|
||||||
// the onboarding RPC returns immediately.
|
// the onboarding RPC returns immediately.
|
||||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||||
|
|
||||||
|
|||||||
@ -61,28 +61,6 @@ impl RpcHandler {
|
|||||||
|
|
||||||
info!("Server name updated to: {}", name);
|
info!("Server name updated to: {}", name);
|
||||||
|
|
||||||
// Propagate to the mesh: the listener advertises the server name (when
|
|
||||||
// no explicit mesh advert_name overrides it), but it was only read at
|
|
||||||
// process startup — a rename never reached the radio/RNS until the
|
|
||||||
// next full restart. Push it into the service and bounce the listener
|
|
||||||
// in the background (the restart re-probes the radio, which can take
|
|
||||||
// seconds — don't block the rename response on it).
|
|
||||||
{
|
|
||||||
let mesh_arc = self.mesh_service_arc();
|
|
||||||
let name_for_mesh = name.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut guard = mesh_arc.write().await;
|
|
||||||
if let Some(svc) = guard.as_mut() {
|
|
||||||
svc.set_server_name(Some(name_for_mesh));
|
|
||||||
if svc.config().advert_name.is_none() {
|
|
||||||
if let Err(e) = svc.restart_listener_if_running().await {
|
|
||||||
warn!("Mesh listener restart after rename failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the new name to federation peers in background
|
// Push the new name to federation peers in background
|
||||||
let data_dir = self.config.data_dir.clone();
|
let data_dir = self.config.data_dir.clone();
|
||||||
let state_manager = self.state_manager.clone();
|
let state_manager = self.state_manager.clone();
|
||||||
|
|||||||
@ -498,9 +498,7 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(data_dir);
|
|
||||||
match req.send_json(&payload).await {
|
match req.send_json(&payload).await {
|
||||||
Ok((_, transport)) => {
|
Ok((_, transport)) => {
|
||||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||||
|
|||||||
@ -373,42 +373,6 @@ async fn run_runtime_assets() -> Result<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packaged radio tools (v1.7.118+): OTA updates only apply the backend
|
|
||||||
// binary and frontend tarball, so these PyInstaller binaries ride the
|
|
||||||
// runtime payload and get promoted here. Without this, fleet nodes keep
|
|
||||||
// a stale archy-reticulum-daemon (which exits on flags it doesn't know —
|
|
||||||
// the v1.7.117 --enable-transport rollout killed their mesh sessions)
|
|
||||||
// and never receive archy-rnodeconf at all (Flash LoRa: "No such file
|
|
||||||
// or directory"). Skipped when byte-identical; a running daemon is
|
|
||||||
// unaffected (install replaces the inode) and picks the new binary up
|
|
||||||
// on its next spawn.
|
|
||||||
for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] {
|
|
||||||
let src = runtime_dir.join("radio-tools").join(tool);
|
|
||||||
if !src.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let dest = format!("/usr/local/bin/{}", tool);
|
|
||||||
let same = match (fs::read(&src).await, fs::read(&dest).await) {
|
|
||||||
(Ok(a), Ok(b)) => a == b,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
if same {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let src_s = src.to_string_lossy().to_string();
|
|
||||||
let status = host_sudo(&["install", "-m", "755", &src_s, &dest])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("install {}", tool))?;
|
|
||||||
if !status.success() {
|
|
||||||
anyhow::bail!("install {} exited with {}", tool, status);
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
tool,
|
|
||||||
"Promoted packaged radio tool from OTA runtime payload"
|
|
||||||
);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||||
if nginx_src.exists() {
|
if nginx_src.exists() {
|
||||||
|
|||||||
@ -22,10 +22,8 @@
|
|||||||
//! single declarative call.
|
//! single declarative call.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{LazyLock, Mutex};
|
use std::time::Duration;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@ -37,15 +35,6 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
|||||||
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||||
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
/// After a failed repair (image build/pull included), leave the companion
|
|
||||||
/// alone for this long. Without it, a node under IO pressure retried a 900s
|
|
||||||
/// image build every 30s reconcile tick — each build pegging the disk that
|
|
||||||
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
|
|
||||||
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
|
|
||||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
|
|
||||||
|
|
||||||
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
/// Static description of one companion. The full list per backend
|
/// Static description of one companion. The full list per backend
|
||||||
/// app_id lives in `companions_for`.
|
/// app_id lives in `companions_for`.
|
||||||
@ -475,28 +464,12 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
match needs_repair(spec).await {
|
match needs_repair(spec).await {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
if let Some(failed_at) =
|
|
||||||
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
|
|
||||||
{
|
|
||||||
if failed_at.elapsed() < REPAIR_COOLDOWN {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!(
|
info!(
|
||||||
companion = spec.name,
|
companion = spec.name,
|
||||||
"reconcile: companion not active, repairing"
|
"reconcile: companion not active, repairing"
|
||||||
);
|
);
|
||||||
match install_one(spec).await {
|
if let Err(e) = install_one(spec).await {
|
||||||
Ok(()) => {
|
failures.push((spec.name.to_string(), e));
|
||||||
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
REPAIR_FAILED_AT
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(spec.name, Instant::now());
|
|
||||||
failures.push((spec.name.to_string(), e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -511,58 +484,19 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
|
|
||||||
/// Does this companion need install_one to be re-run? Returns true if
|
/// Does this companion need install_one to be re-run? Returns true if
|
||||||
/// the unit file is missing, stale, or the service is not active.
|
/// the unit file is missing, stale, or the service is not active.
|
||||||
///
|
|
||||||
/// This probe runs every reconcile tick for every companion, so it must be
|
|
||||||
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
|
|
||||||
/// to render the expected unit — under IO pressure the image-existence check
|
|
||||||
/// inside timed out, read as "image missing", and a 900s `podman build` ran
|
|
||||||
/// inside the probe even though the companion was up (the .198 load spiral).
|
|
||||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||||
let dir = quadlet::unit_dir().await?;
|
let dir = quadlet::unit_dir().await?;
|
||||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let svc = format!("{}.service", spec.name);
|
let expected_image = ensure_image_present(spec).await?;
|
||||||
// A hung `systemctl is-active` under IO pressure must not read as
|
let expected_unit = build_unit(spec, &expected_image);
|
||||||
// "companion dead" — that's a repair (and possibly an image build) fired
|
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||||
// off exactly when the node can least afford one.
|
|
||||||
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
|
|
||||||
Ok(active) => {
|
|
||||||
if !active {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
companion = spec.name,
|
|
||||||
"is-active probe timed out; assuming active"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Service is running. Flag it stale only on definitive, cheap signals:
|
|
||||||
// the on-disk unit matching none of the image refs install_one could
|
|
||||||
// have written, or a local build context newer than the built image.
|
|
||||||
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
|
|
||||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
|
||||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
|
||||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
|
||||||
let matches_known_shape = [&local_image, &local_image_compat, ®istry_image]
|
|
||||||
.iter()
|
|
||||||
.any(|img| build_unit(spec, img).render() == on_disk);
|
|
||||||
if !matches_known_shape {
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
let svc = format!("{}.service", spec.name);
|
||||||
for dir in spec.build_dir_candidates {
|
Ok(!quadlet::is_active(&svc).await)
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
|
||||||
// Conservative on any timeout/error inside: reuse the cache.
|
|
||||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -212,21 +212,9 @@ impl DockerPackageScanner {
|
|||||||
website: lan_address.clone(),
|
website: lan_address.clone(),
|
||||||
tier: Some(metadata.tier.to_string()),
|
tier: Some(metadata.tier.to_string()),
|
||||||
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||||
// `ui` is no longer implied by a published port: a
|
|
||||||
// headless backend with an exposed port is a service,
|
|
||||||
// not a launchable app. ui_detection consults the
|
|
||||||
// manifest declaration first, then HTTP-probes the
|
|
||||||
// port. Addresses stay present either way so the
|
|
||||||
// Services tab can still show where a backend lives.
|
|
||||||
let has_ui = super::ui_detection::has_web_ui(
|
|
||||||
&app_id,
|
|
||||||
lan_address.as_deref(),
|
|
||||||
package_state == PackageState::Running,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Some(Interfaces {
|
Some(Interfaces {
|
||||||
main: Some(MainInterface {
|
main: Some(MainInterface {
|
||||||
ui: has_ui.then(|| "true".to_string()),
|
ui: Some("true".to_string()),
|
||||||
tor_config: tor_address.clone(),
|
tor_config: tor_address.clone(),
|
||||||
lan_config: None,
|
lan_config: None,
|
||||||
}),
|
}),
|
||||||
@ -741,7 +729,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
|||||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||||
pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
|
fn launch_url_port(url: &str) -> Option<u16> {
|
||||||
let after_colon = url.rsplit_once(':')?.1;
|
let after_colon = url.rsplit_once(':')?.1;
|
||||||
after_colon
|
after_colon
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
@ -15,7 +15,6 @@ pub mod quadlet;
|
|||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod ui_detection;
|
|
||||||
pub mod version_config;
|
pub mod version_config;
|
||||||
|
|
||||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||||
|
|||||||
@ -1,243 +0,0 @@
|
|||||||
//! Web-UI detection for discovered containers.
|
|
||||||
//!
|
|
||||||
//! The packages list used to mark every container that published a port (or
|
|
||||||
//! carried an onion address) as a UI app, which gave headless backends —
|
|
||||||
//! databases, media servers, self-deployed compose stacks — a Launch button.
|
|
||||||
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
|
|
||||||
//! alike:
|
|
||||||
//!
|
|
||||||
//! 1. A manifest that declares an `interfaces:` block is definitive: any
|
|
||||||
//! entry of `type: ui` means a browsable UI, a block without one means a
|
|
||||||
//! backend service. The signed catalog overlay is consulted before disk
|
|
||||||
//! manifests (catalog supremacy).
|
|
||||||
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
|
|
||||||
//! and manifest-less containers fall through to a short HTTP probe of the
|
|
||||||
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
|
|
||||||
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
|
|
||||||
//!
|
|
||||||
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
|
|
||||||
//! is often just an app that hasn't finished starting.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
|
||||||
/// A confirmed UI stays a UI — re-check rarely.
|
|
||||||
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
|
|
||||||
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
|
|
||||||
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
|
|
||||||
|
|
||||||
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
|
|
||||||
/// launch candidate already computed by the package scanner (host-published),
|
|
||||||
/// and `running` gates the probe: a stopped container can't answer, and a
|
|
||||||
/// dead-port verdict against it would poison the cache.
|
|
||||||
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
|
|
||||||
if let Some(declared) = manifest_declares_ui(app_id) {
|
|
||||||
return declared;
|
|
||||||
}
|
|
||||||
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !running {
|
|
||||||
// Serve a cached verdict if we have one, but never record one.
|
|
||||||
return cached_verdict(app_id, port).unwrap_or(false);
|
|
||||||
}
|
|
||||||
if let Some(v) = cached_verdict(app_id, port) {
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
let verdict = probe_port(port).await;
|
|
||||||
debug!(app_id, port, verdict, "web-UI probe");
|
|
||||||
cache_verdict(app_id, port, verdict);
|
|
||||||
verdict
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Manifest declarations ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
|
|
||||||
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
|
|
||||||
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
|
|
||||||
/// let the probe decide.
|
|
||||||
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
|
|
||||||
// Catalog overlay first: disk manifests don't apply to catalog-covered
|
|
||||||
// apps, so the catalog must win where it speaks.
|
|
||||||
for (id, value) in super::app_catalog::catalog_manifest_values() {
|
|
||||||
if id == app_id {
|
|
||||||
if let Some(v) = declared_ui_in_value(&value) {
|
|
||||||
return Some(v);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for path in disk_manifest_candidates(app_id) {
|
|
||||||
if !path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match archipelago_container::AppManifest::from_file(&path) {
|
|
||||||
Ok(m) => {
|
|
||||||
if m.app.interfaces.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
return Some(m.app.interfaces.values().any(|i| i.interface_type == "ui"));
|
|
||||||
}
|
|
||||||
// Malformed manifests are already reported by the orchestrator's
|
|
||||||
// loader; here they simply don't count as a declaration.
|
|
||||||
Err(_) => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same declaration logic against a catalog manifest carried as raw JSON.
|
|
||||||
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
|
|
||||||
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
|
|
||||||
if interfaces.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(interfaces.values().any(|i| {
|
|
||||||
// An omitted `type` defaults to "ui", mirroring the YAML schema.
|
|
||||||
i.get("type")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.map(|t| t == "ui")
|
|
||||||
.unwrap_or(true)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
|
|
||||||
let mut roots: Vec<std::path::PathBuf> = Vec::new();
|
|
||||||
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
|
|
||||||
let v = v.trim();
|
|
||||||
if !v.is_empty() {
|
|
||||||
roots.push(v.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
roots.push("/opt/archipelago/apps".into());
|
|
||||||
roots
|
|
||||||
.into_iter()
|
|
||||||
.map(|root| root.join(app_id).join("manifest.yml"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTTP probe ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn probe_port(port: u16) -> bool {
|
|
||||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
|
||||||
let client = CLIENT.get_or_init(|| {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.timeout(PROBE_TIMEOUT)
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.build()
|
|
||||||
.expect("static probe client")
|
|
||||||
});
|
|
||||||
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let content_type = resp
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("");
|
|
||||||
let auth_challenge = resp
|
|
||||||
.headers()
|
|
||||||
.contains_key(reqwest::header::WWW_AUTHENTICATE);
|
|
||||||
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
|
|
||||||
}
|
|
||||||
// Connection refused, timeout, or a non-HTTP protocol on the port.
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pure classification rule, split out for tests: a browsable UI is an
|
|
||||||
/// HTML response (any status — error pages included), a redirect (login
|
|
||||||
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
|
|
||||||
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
|
|
||||||
if content_type.contains("text/html") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (300..400).contains(&status) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (status == 401 || status == 403) && auth_challenge {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Verdict cache ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
|
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
|
|
||||||
CACHE.get_or_init(Default::default)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
|
|
||||||
let cache = cache().lock().ok()?;
|
|
||||||
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
|
|
||||||
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
|
|
||||||
(at.elapsed() < ttl).then_some(*verdict)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
|
|
||||||
if let Ok(mut cache) = cache().lock() {
|
|
||||||
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn html_is_ui_regardless_of_status() {
|
|
||||||
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
|
|
||||||
assert!(ui_verdict(404, "text/html", false));
|
|
||||||
assert!(ui_verdict(401, "text/html", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redirects_are_ui() {
|
|
||||||
assert!(ui_verdict(302, "", false));
|
|
||||||
assert!(ui_verdict(307, "text/plain", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auth_walls_are_ui_only_with_challenge() {
|
|
||||||
assert!(ui_verdict(401, "text/plain", true));
|
|
||||||
assert!(!ui_verdict(401, "application/json", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apis_and_plain_ports_are_services() {
|
|
||||||
assert!(!ui_verdict(200, "application/json", false));
|
|
||||||
assert!(!ui_verdict(200, "text/plain", false));
|
|
||||||
assert!(!ui_verdict(500, "", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn catalog_value_declaration() {
|
|
||||||
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
|
|
||||||
assert_eq!(declared_ui_in_value(&ui), Some(true));
|
|
||||||
|
|
||||||
// Omitted type defaults to "ui", mirroring the YAML schema default.
|
|
||||||
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
|
|
||||||
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
|
|
||||||
|
|
||||||
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
|
|
||||||
assert_eq!(declared_ui_in_value(&api_only), Some(false));
|
|
||||||
|
|
||||||
// No interfaces block ⇒ undeclared, not "no UI".
|
|
||||||
let none = json!({"app": {"id": "x"}});
|
|
||||||
assert_eq!(declared_ui_in_value(&none), None);
|
|
||||||
let empty = json!({"app": {"interfaces": {}}});
|
|
||||||
assert_eq!(declared_ui_in_value(&empty), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verdict_cache_roundtrip() {
|
|
||||||
cache_verdict("test-app", 1234, true);
|
|
||||||
assert_eq!(cached_verdict("test-app", 1234), Some(true));
|
|
||||||
assert_eq!(cached_verdict("test-app", 9999), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -232,32 +232,26 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
|||||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||||
/// a slow Tor timeout.
|
/// a slow Tor timeout.
|
||||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||||
// Concurrent, each connect hard-capped: the old serial loop waited
|
let mut results = Vec::with_capacity(anchors.len());
|
||||||
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
|
for anchor in anchors {
|
||||||
// stalled the whole apply — and the periodic anchor tick behind it,
|
let out = Command::new("sudo")
|
||||||
// which is exactly when a wedged daemon most needs the re-apply.
|
.args([
|
||||||
let futs = anchors.iter().cloned().map(|anchor| async move {
|
"-n",
|
||||||
let out = tokio::time::timeout(
|
"fipsctl",
|
||||||
std::time::Duration::from_secs(15),
|
"connect",
|
||||||
Command::new("sudo")
|
&anchor.npub,
|
||||||
.args([
|
&anchor.address,
|
||||||
"-n",
|
&anchor.transport,
|
||||||
"fipsctl",
|
])
|
||||||
"connect",
|
.output()
|
||||||
&anchor.npub,
|
.await;
|
||||||
&anchor.address,
|
|
||||||
&anchor.transport,
|
|
||||||
])
|
|
||||||
.output(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let result = match out {
|
let result = match out {
|
||||||
Ok(Ok(o)) if o.status.success() => ApplyResult {
|
Ok(o) if o.status.success() => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: true,
|
ok: true,
|
||||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||||
},
|
},
|
||||||
Ok(Ok(o)) => ApplyResult {
|
Ok(o) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!(
|
message: format!(
|
||||||
@ -266,16 +260,11 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Ok(Err(e)) => ApplyResult {
|
Err(e) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!("sudo fipsctl launch failed: {}", e),
|
message: format!("sudo fipsctl launch failed: {}", e),
|
||||||
},
|
},
|
||||||
Err(_) => ApplyResult {
|
|
||||||
npub: anchor.npub.clone(),
|
|
||||||
ok: false,
|
|
||||||
message: "sudo fipsctl connect timed out after 15s".to_string(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
if result.ok {
|
if result.ok {
|
||||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||||
@ -286,9 +275,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
"Seed anchor apply failed (non-fatal)"
|
"Seed anchor apply failed (non-fatal)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
result
|
results.push(result);
|
||||||
});
|
}
|
||||||
futures_util::future::join_all(futs).await
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of a single `fipsctl connect` call.
|
/// Outcome of a single `fipsctl connect` call.
|
||||||
@ -301,12 +290,12 @@ pub struct ApplyResult {
|
|||||||
|
|
||||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||||
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||||
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
const FIPS_UDP_PORT: u16 = 8668;
|
||||||
|
|
||||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||||
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||||
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||||
/// instead of depending on the global anchor's spanning tree to route between
|
/// instead of depending on the global anchor's spanning tree to route between
|
||||||
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||||
///
|
///
|
||||||
@ -459,39 +448,4 @@ mod tests {
|
|||||||
assert_eq!(a.transport, "udp");
|
assert_eq!(a.transport, "udp");
|
||||||
assert_eq!(a.label, "");
|
assert_eq!(a.label, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lan_fips_anchor_port_matches_daemon_bind() {
|
|
||||||
// Drift guard: direct LAN anchors must dial the UDP port the
|
|
||||||
// generated fips.yaml actually binds. These were out of sync for
|
|
||||||
// months (anchors dialed 8668, the daemon bound 2121), making the
|
|
||||||
// whole direct-peering feature dial a dead port.
|
|
||||||
let yaml = crate::fips::config::render_config_yaml();
|
|
||||||
assert!(
|
|
||||||
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
|
|
||||||
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lan_fips_anchors_builds_direct_entry() {
|
|
||||||
let peer = crate::transport::PeerRecord {
|
|
||||||
did: "did:key:zpeer".to_string(),
|
|
||||||
lan_address: Some("192.168.63.198:5678".to_string()),
|
|
||||||
fips_npub: Some("npub1peer".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let out = lan_fips_anchors(&[peer]);
|
|
||||||
assert_eq!(out.len(), 1);
|
|
||||||
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
|
|
||||||
assert_eq!(out[0].transport, "udp");
|
|
||||||
|
|
||||||
// Peers missing either the LAN address or the npub produce nothing.
|
|
||||||
let no_npub = crate::transport::PeerRecord {
|
|
||||||
did: "did:key:zother".to_string(),
|
|
||||||
lan_address: Some("192.168.63.199:5678".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(lan_fips_anchors(&[no_npub]).is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,39 @@
|
|||||||
//! no listener, so allowing them is inert.
|
//! no listener, so allowing them is inert.
|
||||||
|
|
||||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
2283,
|
||||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
2342,
|
||||||
18083, 23000, 32838, 50002,
|
3000,
|
||||||
|
3001,
|
||||||
|
3002,
|
||||||
|
4080,
|
||||||
|
5180,
|
||||||
|
7778,
|
||||||
|
8080,
|
||||||
|
8081,
|
||||||
|
8082,
|
||||||
|
8083,
|
||||||
|
8084,
|
||||||
|
8085,
|
||||||
|
8087,
|
||||||
|
8088,
|
||||||
|
8089,
|
||||||
|
8090,
|
||||||
|
8096,
|
||||||
|
8123,
|
||||||
|
8175,
|
||||||
|
8176,
|
||||||
|
8240,
|
||||||
|
8334,
|
||||||
|
8888,
|
||||||
|
8999,
|
||||||
|
9000,
|
||||||
|
9100,
|
||||||
|
10380,
|
||||||
|
11434,
|
||||||
|
18081,
|
||||||
|
18083,
|
||||||
|
23000,
|
||||||
|
32838,
|
||||||
|
50002,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -240,21 +240,11 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
|||||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||||
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
|
let dropin = "# Written by archipelago on every daemon config install.\n\
|
||||||
// mesh envelopes, DWN and invoices. It was missing from this allowlist
|
# Allows the web UI + peer API through the fips0\n\
|
||||||
// while the comment claimed "web UI + peer API" — so every hardened
|
# default-deny inbound baseline (fips.nft).\n\
|
||||||
// node silently dropped peers' FIPS dials at the firewall and the whole
|
tcp dport 80 accept\n\
|
||||||
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
|
tcp dport 8443 accept\n";
|
||||||
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
|
|
||||||
let dropin = format!(
|
|
||||||
"# Written by archipelago on every daemon config install.\n\
|
|
||||||
# Allows the web UI + peer API through the fips0\n\
|
|
||||||
# default-deny inbound baseline (fips.nft).\n\
|
|
||||||
tcp dport 80 accept\n\
|
|
||||||
tcp dport 8443 accept\n\
|
|
||||||
tcp dport {peer_port} accept\n",
|
|
||||||
peer_port = crate::fips::dial::PEER_PORT
|
|
||||||
);
|
|
||||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||||
tokio::fs::write(&nft_stage, dropin)
|
tokio::fs::write(&nft_stage, dropin)
|
||||||
.await
|
.await
|
||||||
@ -287,10 +277,7 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
|||||||
app_install?;
|
app_install?;
|
||||||
// Make the allowance live immediately; a no-op error when the
|
// Make the allowance live immediately; a no-op error when the
|
||||||
// hardening baseline isn't installed on this node yet.
|
// hardening baseline isn't installed on this node yet.
|
||||||
if tokio::fs::try_exists("/etc/fips/fips.nft")
|
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
|
||||||
.await
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
match Command::new("sudo")
|
match Command::new("sudo")
|
||||||
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
//! ```
|
//! ```
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use super::telemetry::{self, FallbackReason};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::net::{IpAddr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv6Addr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -151,12 +150,6 @@ pub async fn warm_path(npub: &str) {
|
|||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
warm_path_unchecked(npub).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`warm_path`] without the service-active check — for callers (the warm
|
|
||||||
/// tick) that already verified the daemon once for the whole batch.
|
|
||||||
pub async fn warm_path_unchecked(npub: &str) {
|
|
||||||
let Ok(base) = peer_base_url(npub).await else {
|
let Ok(base) = peer_base_url(npub).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@ -283,39 +276,21 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
|||||||
|
|
||||||
// ── High-level peer request helpers ────────────────────────────────────
|
// ── High-level peer request helpers ────────────────────────────────────
|
||||||
|
|
||||||
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
|
|
||||||
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
|
|
||||||
/// service state changes on human timescales, so 10s staleness is free.
|
|
||||||
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
|
|
||||||
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
||||||
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||||
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
|
/// currently `systemctl is-active`? Async wrapper intended for the
|
||||||
/// concurrent refreshes are harmless (idempotent probe, last write wins).
|
/// migration call sites; unlike `FipsTransport::is_available` this does
|
||||||
|
/// not maintain a cache, so callers that poll frequently should cache
|
||||||
|
/// themselves.
|
||||||
pub async fn is_service_active() -> bool {
|
pub async fn is_service_active() -> bool {
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
let now_ms = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
|
|
||||||
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
|
|
||||||
return SERVICE_ACTIVE.load(Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let mut active = false;
|
|
||||||
for unit in [
|
for unit in [
|
||||||
crate::fips::SERVICE_UNIT,
|
crate::fips::SERVICE_UNIT,
|
||||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||||
] {
|
] {
|
||||||
if crate::fips::service::unit_state(unit).await == "active" {
|
if crate::fips::service::unit_state(unit).await == "active" {
|
||||||
active = true;
|
return true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
|
false
|
||||||
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
|
|
||||||
active
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||||
@ -342,11 +317,6 @@ pub struct PeerRequest<'a> {
|
|||||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||||
pub fips_timeout: Option<std::time::Duration>,
|
pub fips_timeout: Option<std::time::Duration>,
|
||||||
pub service: Option<crate::settings::transport::PeerService>,
|
pub service: Option<crate::settings::transport::PeerService>,
|
||||||
/// When set, the transport that actually served this request is written
|
|
||||||
/// to federation storage (`record_peer_transport`, matched by onion) so
|
|
||||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
|
||||||
/// every caller has a data dir in scope.
|
|
||||||
pub record_data_dir: Option<std::path::PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PeerRequest<'a> {
|
impl<'a> PeerRequest<'a> {
|
||||||
@ -359,27 +329,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
timeout: std::time::Duration::from_secs(30),
|
timeout: std::time::Duration::from_secs(30),
|
||||||
fips_timeout: None,
|
fips_timeout: None,
|
||||||
service: None,
|
service: None,
|
||||||
record_data_dir: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record the transport that serves this request into federation storage
|
|
||||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
|
||||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
|
||||||
self.record_data_dir = Some(data_dir.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_record(&self, kind: crate::transport::TransportKind) {
|
|
||||||
if let Some(dir) = &self.record_data_dir {
|
|
||||||
let dir = dir.clone();
|
|
||||||
let onion = self.onion_host.to_string();
|
|
||||||
let transport = kind.to_string();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ =
|
|
||||||
crate::federation::record_peer_transport(&dir, None, Some(&onion), &transport)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,22 +389,8 @@ impl<'a> PeerRequest<'a> {
|
|||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
// fall back. FIPS-only never falls back.
|
// fall back. FIPS-only never falls back.
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
}
|
}
|
||||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
FallbackReason::Http404
|
|
||||||
} else {
|
|
||||||
FallbackReason::Http5xx
|
|
||||||
};
|
|
||||||
telemetry::record_fallback(reason);
|
|
||||||
tracing::info!(
|
|
||||||
reason = reason.key(),
|
|
||||||
status = %resp.status(),
|
|
||||||
"FIPS POST {} answered but status triggers Tor fallback",
|
|
||||||
self.path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -467,7 +402,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_post_json(body).await?;
|
let resp = self.send_tor_post_json(body).await?;
|
||||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,22 +413,8 @@ impl<'a> PeerRequest<'a> {
|
|||||||
match self.try_fips_get().await? {
|
match self.try_fips_get().await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
}
|
}
|
||||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
FallbackReason::Http404
|
|
||||||
} else {
|
|
||||||
FallbackReason::Http5xx
|
|
||||||
};
|
|
||||||
telemetry::record_fallback(reason);
|
|
||||||
tracing::info!(
|
|
||||||
reason = reason.key(),
|
|
||||||
status = %resp.status(),
|
|
||||||
"FIPS GET {} answered but status triggers Tor fallback",
|
|
||||||
self.path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -506,7 +426,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_get().await?;
|
let resp = self.send_tor_get().await?;
|
||||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,127 +434,67 @@ impl<'a> PeerRequest<'a> {
|
|||||||
body: &B,
|
body: &B,
|
||||||
) -> Result<Option<reqwest::Response>> {
|
) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::DnsFail.key(),
|
|
||||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
|
||||||
npub,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let budget = self.fips_attempt_timeout();
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
// With an explicit fast-fail cap, halve the per-attempt client
|
|
||||||
// timeout so the one-retry path in send_with_retry fits inside the
|
|
||||||
// budget instead of silently doubling it ("fips_timeout(6s)" used
|
|
||||||
// to really mean ~12.6s). Without one (long streaming downloads),
|
|
||||||
// keep the full budget per attempt — the client timeout also
|
|
||||||
// governs body streaming and must not truncate a real transfer.
|
|
||||||
let per_attempt = if self.fips_timeout.is_some() {
|
|
||||||
budget / 2
|
|
||||||
} else {
|
|
||||||
budget
|
|
||||||
};
|
|
||||||
let c = client_with_timeout(per_attempt);
|
|
||||||
let mut rb = c.post(&url).json(body);
|
let mut rb = c.post(&url).json(body);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
match send_with_retry(rb).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(r) => Ok(Some(r)),
|
||||||
Ok(Err(e)) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
tracing::debug!(
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
|
|
||||||
url,
|
|
||||||
budget
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::DnsFail.key(),
|
|
||||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
|
||||||
npub,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let budget = self.fips_attempt_timeout();
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
// Same budget discipline as the POST path: halve per attempt only
|
|
||||||
// under an explicit fast-fail cap; hard-cap the retry sequence.
|
|
||||||
let per_attempt = if self.fips_timeout.is_some() {
|
|
||||||
budget / 2
|
|
||||||
} else {
|
|
||||||
budget
|
|
||||||
};
|
|
||||||
let c = client_with_timeout(per_attempt);
|
|
||||||
let mut rb = c.get(&url);
|
let mut rb = c.get(&url);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
match send_with_retry(rb).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(r) => Ok(Some(r)),
|
||||||
Ok(Err(e)) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
tracing::debug!(
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
|
|
||||||
url,
|
|
||||||
budget
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,6 @@ pub mod config;
|
|||||||
pub mod dial;
|
pub mod dial;
|
||||||
pub mod iface;
|
pub mod iface;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod telemetry;
|
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -55,8 +54,7 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let unit = service::activation_unit().await;
|
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||||
if let Err(e) = service::activate(unit).await {
|
|
||||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -80,71 +78,25 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
// Connectivity watcher state: re-apply seed anchors the moment the
|
|
||||||
// anchor link drops (edge) or the data path degrades (dials keep
|
|
||||||
// failing with zero successes), instead of waiting for the 300s
|
|
||||||
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
|
|
||||||
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
|
|
||||||
let mut prev_connected: Option<bool> = None;
|
|
||||||
let mut prev_totals = telemetry::totals();
|
|
||||||
let mut last_apply: Option<std::time::Instant> = None;
|
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||||
ensure_activated(&data_dir).await;
|
ensure_activated(&data_dir).await;
|
||||||
if !dial::is_service_active().await {
|
if !dial::is_service_active().await {
|
||||||
prev_connected = None; // daemon restart = fresh edge detection
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Warm the union of federation peers + configured seed
|
|
||||||
// anchors. Warming only federation npubs left the direct
|
|
||||||
// anchors (vps2, LAN peers) to go cold between 300s ticks.
|
|
||||||
let nodes = crate::federation::load_nodes(&data_dir)
|
let nodes = crate::federation::load_nodes(&data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
|
||||||
let mut warm_npubs: std::collections::BTreeSet<String> =
|
|
||||||
nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
|
|
||||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for npub in warm_npubs {
|
for node in nodes {
|
||||||
// Service-active was checked once above for the whole batch.
|
if let Some(npub) = node.fips_npub.clone() {
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||||
dial::warm_path_unchecked(&npub).await
|
}
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
for h in handles {
|
for h in handles {
|
||||||
let _ = h.await;
|
let _ = h.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connectivity watcher: detect anchor-link loss AND silent
|
|
||||||
// data-path death (daemon reports "connected" but every dial
|
|
||||||
// connect-fails — observed live on .198, 2026-07-27, where the
|
|
||||||
// 300s tick never healed it).
|
|
||||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
|
||||||
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
|
||||||
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
|
|
||||||
let totals = telemetry::totals();
|
|
||||||
let link_dropped = prev_connected == Some(true) && !connected;
|
|
||||||
let never_connected = prev_connected.is_none() && !connected;
|
|
||||||
let data_path_dead =
|
|
||||||
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
|
|
||||||
prev_connected = Some(connected);
|
|
||||||
prev_totals = totals;
|
|
||||||
|
|
||||||
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
|
|
||||||
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
|
|
||||||
{
|
|
||||||
tracing::info!(
|
|
||||||
link_dropped,
|
|
||||||
never_connected,
|
|
||||||
data_path_dead,
|
|
||||||
"FIPS connectivity degraded — re-applying seed anchors now"
|
|
||||||
);
|
|
||||||
last_apply = Some(std::time::Instant::now());
|
|
||||||
let _ = anchors::apply(&seed).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user