Compare commits
No commits in common. "main" and "archy-hwconfig" have entirely different histories.
main
...
archy-hwco
@ -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,110 +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
|
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|-------------|-------|--------|
|
|
||||||
| MNODE-01 | Phase 1 | Pending |
|
|
||||||
| MNODE-02 | Phase 1 | Pending |
|
|
||||||
| MNODE-03 | Phase 1 | Pending |
|
|
||||||
| LIFE-01 | Phase 2 | Pending |
|
|
||||||
| LIFE-02 | Phase 2 | Pending |
|
|
||||||
| LIFE-03 | Phase 2 | Pending |
|
|
||||||
| LIFE-04 | Phase 2 | Pending |
|
|
||||||
| LIFE-05 | Phase 2 | Pending |
|
|
||||||
| REG-01 | Phase 3 | Pending |
|
|
||||||
| REG-02 | Phase 3 | Pending |
|
|
||||||
| SEC-01 | Phase 4 | Pending |
|
|
||||||
| SEC-02 | Phase 4 | Pending |
|
|
||||||
| DEV-01 | Phase 5 | Pending |
|
|
||||||
| DEV-02 | Phase 5 | Pending |
|
|
||||||
| DEV-03 | Phase 5 | Pending |
|
|
||||||
| DEV-04 | Phase 5 | Pending |
|
|
||||||
| MKT-01 | Phase 6 | Pending |
|
|
||||||
| MKT-02 | Phase 6 | Pending |
|
|
||||||
| MKT-03 | Phase 6 | Pending |
|
|
||||||
| MKT-04 | Phase 6 | Pending |
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- v1 requirements: 20 total
|
|
||||||
- Mapped to phases: 20
|
|
||||||
- Unmapped: 0
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-07-29*
|
|
||||||
*Last updated: 2026-07-29 after roadmap creation*
|
|
||||||
@ -1,108 +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: 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: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks
|
|
||||||
- [ ] **Phase 2: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress
|
|
||||||
- [ ] **Phase 3: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution
|
|
||||||
- [ ] **Phase 4: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied
|
|
||||||
- [ ] **Phase 5: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide
|
|
||||||
- [ ] **Phase 6: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end
|
|
||||||
|
|
||||||
## Phase Details
|
|
||||||
|
|
||||||
### Phase 1: 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**: Nothing (first phase)
|
|
||||||
**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 2: 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 1 (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 3: 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 2 (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 4: 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 3 (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 5: Developer Tooling CLI
|
|
||||||
**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals
|
|
||||||
**Depends on**: Phase 4 (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 6: 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 5 (publish rides the CLI; installs ride registry distribution from Phase 3 and enforcement from Phase 4)
|
|
||||||
**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
|
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
|
||||||
|-------|----------------|--------|-----------|
|
|
||||||
| 1. Multinode Verification Pass | 0/TBD | Not started | - |
|
|
||||||
| 2. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
|
||||||
| 3. Registry-Distributed Manifests | 0/TBD | Not started | - |
|
|
||||||
| 4. Manifest Security Enforcement | 0/TBD | Not started | - |
|
|
||||||
| 5. Developer Tooling CLI | 0/TBD | Not started | - |
|
|
||||||
| 6. Decentralized Marketplace | 0/TBD | Not started | - |
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
gsd_state_version: '1.0'
|
|
||||||
status: planning
|
|
||||||
progress:
|
|
||||||
total_phases: 6
|
|
||||||
completed_phases: 0
|
|
||||||
total_plans: 0
|
|
||||||
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 — Multinode Verification Pass
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 1 of 6 (Multinode Verification Pass)
|
|
||||||
Plan: 0 of TBD in current phase
|
|
||||||
Status: Ready to plan
|
|
||||||
Last activity: 2026-07-29 — Project initialized from doc ingest (10 ADRs + 1 SPEC) + codebase map; roadmap created (6 phases, 20 requirements, 100% coverage)
|
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
**Velocity:**
|
|
||||||
- Total plans completed: 0
|
|
||||||
- Average duration: —
|
|
||||||
- Total execution time: —
|
|
||||||
|
|
||||||
**By Phase:**
|
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
|
||||||
|-------|-------|-------|----------|
|
|
||||||
| - | - | - | - |
|
|
||||||
|
|
||||||
## Accumulated Context
|
|
||||||
|
|
||||||
### 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] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running
|
|
||||||
- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind
|
|
||||||
- [Phase 3] Fleet registry flip awaits explicit user authorization + timing call
|
|
||||||
- [Phase 4] 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)
|
|
||||||
|
|
||||||
## 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-29
|
|
||||||
Stopped at: Roadmap + state initialized from ingest; next step is `/gsd-plan-phase 1`
|
|
||||||
Resume file: None
|
|
||||||
@ -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
|
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
@ -1,29 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v1.7.118-alpha (2026-07-29)
|
## v1.7.117-alpha (2026-07-27)
|
||||||
|
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
|
||||||
- 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.
|
- 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.
|
||||||
|
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
|
||||||
|
|
||||||
## v1.7.116-alpha (2026-07-27)
|
## v1.7.116-alpha (2026-07-27)
|
||||||
|
|
||||||
|
|||||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.118-alpha"
|
version = "1.7.116-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.118-alpha"
|
version = "1.7.116-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"]
|
||||||
|
|||||||
@ -74,18 +74,14 @@ impl RpcHandler {
|
|||||||
// payment settles or definitively fails, and multi-hop routing with
|
// payment settles or definitively fails, and multi-hop routing with
|
||||||
// retries routinely takes longer than the shared client's 15s budget.
|
// retries routinely takes longer than the shared client's 15s budget.
|
||||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||||
// paying in the background — only LND may declare a payment failed,
|
// paying in the background — the payment then succeeded and appeared
|
||||||
// so a post-connect timeout is IN FLIGHT (status: pending), never
|
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||||
// failure. The window is deliberately SHORT: most payments settle in
|
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||||
// a couple of seconds and still get their answer in one round trip,
|
// never as failure — only LND may declare a payment failed.
|
||||||
// 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()
|
let pay_client = reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.connect_timeout(std::time::Duration::from_secs(10))
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
.timeout(std::time::Duration::from_secs(8))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(true)
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
@ -202,7 +198,8 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.and_then(|arr| {
|
.and_then(|arr| {
|
||||||
arr.iter().find(|p| {
|
arr.iter().find(|p| {
|
||||||
p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
|
p.get("payment_hash").and_then(|v| v.as_str())
|
||||||
|
== Some(hash_lower.as_str())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,7 @@ fn parse_family(s: &str) -> Result<DeviceType> {
|
|||||||
"meshcore" => Ok(DeviceType::Meshcore),
|
"meshcore" => Ok(DeviceType::Meshcore),
|
||||||
"meshtastic" => Ok(DeviceType::Meshtastic),
|
"meshtastic" => Ok(DeviceType::Meshtastic),
|
||||||
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
||||||
other => anyhow::bail!(
|
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
|
||||||
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -287,10 +287,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()
|
||||||
|
|||||||
@ -376,9 +376,13 @@ impl<'a> PeerRequest<'a> {
|
|||||||
let onion = self.onion_host.to_string();
|
let onion = self.onion_host.to_string();
|
||||||
let transport = kind.to_string();
|
let transport = kind.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ =
|
let _ = crate::federation::record_peer_transport(
|
||||||
crate::federation::record_peer_transport(&dir, None, Some(&onion), &transport)
|
&dir,
|
||||||
.await;
|
None,
|
||||||
|
Some(&onion),
|
||||||
|
&transport,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,15 +104,17 @@ pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
||||||
let mut warm_npubs: std::collections::BTreeSet<String> =
|
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
|
||||||
nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
|
.iter()
|
||||||
|
.filter_map(|n| n.fips_npub.clone())
|
||||||
|
.collect();
|
||||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
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 npub in warm_npubs {
|
||||||
// Service-active was checked once above for the whole batch.
|
// Service-active was checked once above for the whole batch.
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(
|
||||||
dial::warm_path_unchecked(&npub).await
|
async move { dial::warm_path_unchecked(&npub).await },
|
||||||
}));
|
));
|
||||||
}
|
}
|
||||||
for h in handles {
|
for h in handles {
|
||||||
let _ = h.await;
|
let _ = h.await;
|
||||||
|
|||||||
@ -420,9 +420,7 @@ pub async fn start_flash_job(
|
|||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
bg_job
|
bg_job.push_log("Flash completed successfully".to_string()).await;
|
||||||
.push_log("Flash completed successfully".to_string())
|
|
||||||
.await;
|
|
||||||
bg_job.finish().await;
|
bg_job.finish().await;
|
||||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||||
}
|
}
|
||||||
@ -588,8 +586,7 @@ async fn fetch_meshtastic_image(
|
|||||||
if tokio::fs::metadata(&zip_path).await.is_err() {
|
if tokio::fs::metadata(&zip_path).await.is_err() {
|
||||||
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
||||||
} else {
|
} else {
|
||||||
job.push_log(format!("Using cached {}", zip_asset.name))
|
job.push_log(format!("Using cached {}", zip_asset.name)).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
||||||
@ -597,20 +594,26 @@ async fn fetch_meshtastic_image(
|
|||||||
// erased chip — confirmed by inspecting the real zip's contents, as
|
// erased chip — confirmed by inspecting the real zip's contents, as
|
||||||
// opposed to the plain "*.bin" OTA-update image which assumes an
|
// opposed to the plain "*.bin" OTA-update image which assumes an
|
||||||
// existing bootloader/partition table already on the chip.
|
// existing bootloader/partition table already on the chip.
|
||||||
let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
|
let entry_name = format!(
|
||||||
|
"firmware-{}-{}.factory.bin",
|
||||||
|
board.meshtastic_id(),
|
||||||
|
version
|
||||||
|
);
|
||||||
let out_path = cache.join(&entry_name);
|
let out_path = cache.join(&entry_name);
|
||||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||||
return Ok(out_path);
|
return Ok(out_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
|
job.push_log(format!(
|
||||||
.await;
|
"Extracting {entry_name} from {}",
|
||||||
|
zip_asset.name
|
||||||
|
))
|
||||||
|
.await;
|
||||||
let zip_path_owned = zip_path.clone();
|
let zip_path_owned = zip_path.clone();
|
||||||
let entry_name_owned = entry_name.clone();
|
let entry_name_owned = entry_name.clone();
|
||||||
let out_path_owned = out_path.clone();
|
let out_path_owned = out_path.clone();
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let file =
|
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
||||||
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
|
||||||
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
||||||
let mut entry = archive
|
let mut entry = archive
|
||||||
.by_name(&entry_name_owned)
|
.by_name(&entry_name_owned)
|
||||||
@ -823,18 +826,6 @@ fn rnodeconf_bin() -> String {
|
|||||||
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when `name` resolves to an executable on PATH.
|
|
||||||
fn which_on_path(name: &str) -> bool {
|
|
||||||
std::env::var_os("PATH")
|
|
||||||
.map(|paths| {
|
|
||||||
std::env::split_paths(&paths).any(|dir| {
|
|
||||||
let candidate = dir.join(name);
|
|
||||||
candidate.is_file()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
||||||
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
||||||
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
||||||
@ -889,17 +880,9 @@ async fn rnodeconf_autoinstall(
|
|||||||
let bin = rnodeconf_bin();
|
let bin = rnodeconf_bin();
|
||||||
let mut cmd = if Path::new(&bin).exists() {
|
let mut cmd = if Path::new(&bin).exists() {
|
||||||
Command::new(bin)
|
Command::new(bin)
|
||||||
} else if which_on_path("rnodeconf") {
|
} else {
|
||||||
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
||||||
Command::new("rnodeconf")
|
Command::new("rnodeconf")
|
||||||
} else {
|
|
||||||
// Older ISOs/OTAs never shipped the tool — say so instead of the
|
|
||||||
// bare "No such file or directory" the spawn would produce.
|
|
||||||
anyhow::bail!(
|
|
||||||
"{bin} is not installed on this node — RNode flashing needs the packaged \
|
|
||||||
archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \
|
|
||||||
sideloaded from a dev box). Update the node, then retry."
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
cmd.args(["--autoinstall", path]);
|
cmd.args(["--autoinstall", path]);
|
||||||
let stdin = format!(
|
let stdin = format!(
|
||||||
|
|||||||
@ -1012,48 +1012,31 @@ pub(super) async fn run_mesh_session(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else if let Some(path) = preferred_path {
|
} else if let Some(path) = preferred_path {
|
||||||
if device_kind.is_none() {
|
match open_preferred_path(
|
||||||
// Unpinned: open_preferred_path bails without ever touching
|
path,
|
||||||
// the port, so the preferred path has NOT been probed this
|
data_dir,
|
||||||
// cycle — auto-detect must keep it as a candidate
|
our_ed_pubkey_hex,
|
||||||
// (skip_path=None). Passing Some(path) here excluded the only
|
our_x25519_pubkey_hex,
|
||||||
// radio on single-device nodes and the mesh never came up.
|
device_kind,
|
||||||
auto_detect_and_open(
|
Some(&desired_advert_name),
|
||||||
data_dir,
|
)
|
||||||
our_ed_pubkey_hex,
|
.await
|
||||||
our_x25519_pubkey_hex,
|
{
|
||||||
device_kind,
|
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
||||||
None,
|
Err(e) => {
|
||||||
Some(&desired_advert_name),
|
warn!(
|
||||||
)
|
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||||
.await
|
path, e
|
||||||
} else {
|
);
|
||||||
match open_preferred_path(
|
auto_detect_and_open(
|
||||||
path,
|
data_dir,
|
||||||
data_dir,
|
our_ed_pubkey_hex,
|
||||||
our_ed_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
device_kind,
|
||||||
device_kind,
|
Some(path),
|
||||||
Some(&desired_advert_name),
|
Some(&desired_advert_name),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
|
||||||
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
|
||||||
path, e
|
|
||||||
);
|
|
||||||
auto_detect_and_open(
|
|
||||||
data_dir,
|
|
||||||
our_ed_pubkey_hex,
|
|
||||||
our_x25519_pubkey_hex,
|
|
||||||
device_kind,
|
|
||||||
Some(path),
|
|
||||||
Some(&desired_advert_name),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -802,10 +802,7 @@ impl MeshService {
|
|||||||
// the server name — it existed as write-only config with no reader
|
// the server name — it existed as write-only config with no reader
|
||||||
// until this line, which is why renaming on the Mesh page never
|
// until this line, which is why renaming on the Mesh page never
|
||||||
// changed anything on the air.
|
// changed anything on the air.
|
||||||
self.config
|
self.config.advert_name.clone().or_else(|| self.server_name.clone()),
|
||||||
.advert_name
|
|
||||||
.clone()
|
|
||||||
.or_else(|| self.server_name.clone()),
|
|
||||||
self.config.lora_region.clone(),
|
self.config.lora_region.clone(),
|
||||||
self.config.lora_radio_params,
|
self.config.lora_radio_params,
|
||||||
self.config.channel_name.clone(),
|
self.config.channel_name.clone(),
|
||||||
@ -1336,21 +1333,20 @@ impl MeshService {
|
|||||||
// federation branch: the fall-through LoRa path twin-resolves the
|
// federation branch: the fall-through LoRa path twin-resolves the
|
||||||
// routing key via peer_dest_prefix.
|
// routing key via peer_dest_prefix.
|
||||||
let device_connected = self.state.status.read().await.device_connected;
|
let device_connected = self.state.status.read().await.device_connected;
|
||||||
let radio_twin_reachable =
|
let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && {
|
||||||
is_federation_synthetic && !exceeds_lora && device_connected && {
|
let peers = self.state.peers.read().await;
|
||||||
let peers = self.state.peers.read().await;
|
peers
|
||||||
peers
|
.get(&contact_id)
|
||||||
.get(&contact_id)
|
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
.map(|arch| {
|
||||||
.map(|arch| {
|
peers.values().any(|p| {
|
||||||
peers.values().any(|p| {
|
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
&& p.reachable
|
||||||
&& p.reachable
|
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
||||||
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
})
|
||||||
};
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
let mesh_only_mode = load_config(&self.data_dir)
|
let mesh_only_mode = load_config(&self.data_dir)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@ -115,59 +115,6 @@ fn is_loopback_host(host: &str) -> bool {
|
|||||||
/// Reticulum-carried identity binds onto the existing Archy contact via the
|
/// Reticulum-carried identity binds onto the existing Archy contact via the
|
||||||
/// existing `parse_identity_broadcast`/`handle_identity_received` path,
|
/// existing `parse_identity_broadcast`/`handle_identity_received` path,
|
||||||
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
|
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
|
||||||
/// Resolve the daemon invocation: packaged binary, else the dev venv script.
|
|
||||||
/// `(program, Some(script_arg))` for the venv fallback.
|
|
||||||
fn daemon_program() -> (String, Option<String>) {
|
|
||||||
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
|
||||||
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
|
||||||
if Path::new(&bin).exists() {
|
|
||||||
return (bin, None);
|
|
||||||
}
|
|
||||||
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
|
|
||||||
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
|
|
||||||
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
|
|
||||||
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
|
|
||||||
(py, Some(script))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the installed daemon understands `--enable-transport`. OTA updates
|
|
||||||
/// ship the archipelago binary ahead of the packaged daemon tools, so a fleet
|
|
||||||
/// node can run a new binary against an old daemon — whose argparse EXITS on
|
|
||||||
/// an unknown flag, killing the mesh session on every spawn (live regression,
|
|
||||||
/// framework-pt on v1.7.117). Probe `--help` and only pass the flag when the
|
|
||||||
/// daemon advertises it; an old daemon then runs edge-only exactly as before.
|
|
||||||
async fn daemon_supports_enable_transport() -> bool {
|
|
||||||
let (program, script) = daemon_program();
|
|
||||||
let mut cmd = Command::new(&program);
|
|
||||||
if let Some(script) = &script {
|
|
||||||
cmd.arg(script);
|
|
||||||
}
|
|
||||||
cmd.arg("--help")
|
|
||||||
.stdin(std::process::Stdio::null())
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::null());
|
|
||||||
let out = tokio::time::timeout(Duration::from_secs(10), async { cmd.output().await }).await;
|
|
||||||
match out {
|
|
||||||
Ok(Ok(out)) => {
|
|
||||||
let supported = String::from_utf8_lossy(&out.stdout).contains("--enable-transport");
|
|
||||||
if !supported {
|
|
||||||
warn!(
|
|
||||||
program,
|
|
||||||
"installed reticulum-daemon predates --enable-transport — running edge-only until the daemon tools update"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
supported
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
program,
|
|
||||||
"reticulum-daemon --help probe failed — not passing --enable-transport"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn daemon_command(
|
fn daemon_command(
|
||||||
socket_path: &Path,
|
socket_path: &Path,
|
||||||
iface: &ReticulumInterface<'_>,
|
iface: &ReticulumInterface<'_>,
|
||||||
@ -175,13 +122,21 @@ fn daemon_command(
|
|||||||
archy_ed_pubkey_hex: Option<&str>,
|
archy_ed_pubkey_hex: Option<&str>,
|
||||||
archy_x25519_pubkey_hex: Option<&str>,
|
archy_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
display_name: Option<&str>,
|
||||||
enable_transport: bool,
|
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let (program, script) = daemon_program();
|
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
||||||
let mut cmd = Command::new(program);
|
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
||||||
if let Some(script) = script {
|
let mut cmd = if Path::new(&bin).exists() {
|
||||||
cmd.arg(script);
|
Command::new(bin)
|
||||||
}
|
} else {
|
||||||
|
// Dev fallback: run the script through its venv interpreter.
|
||||||
|
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
|
||||||
|
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
|
||||||
|
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
|
||||||
|
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
|
||||||
|
let mut c = Command::new(py);
|
||||||
|
c.arg(script);
|
||||||
|
c
|
||||||
|
};
|
||||||
cmd.arg("--identity-key")
|
cmd.arg("--identity-key")
|
||||||
.arg(identity_key)
|
.arg(identity_key)
|
||||||
.arg("--socket")
|
.arg("--socket")
|
||||||
@ -199,15 +154,6 @@ fn daemon_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Archy nodes are RNS transport nodes: they relay traffic and rebroadcast
|
|
||||||
// announces, so archy nodes (and Sideband/NomadNet peers) beyond direct RF
|
|
||||||
// range discover and reach each other through any archy node in between.
|
|
||||||
// Edge-only operation (`enable_transport = no`) left every node an island
|
|
||||||
// limited to its own radio horizon. Gated on the installed daemon actually
|
|
||||||
// supporting the flag — see `daemon_supports_enable_transport`.
|
|
||||||
if enable_transport {
|
|
||||||
cmd.arg("--enable-transport");
|
|
||||||
}
|
|
||||||
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
||||||
cmd.arg("--archy-ed-pubkey-hex")
|
cmd.arg("--archy-ed-pubkey-hex")
|
||||||
.arg(ed)
|
.arg(ed)
|
||||||
@ -453,7 +399,6 @@ impl ReticulumLink {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let enable_transport = daemon_supports_enable_transport().await;
|
|
||||||
let mut cmd = daemon_command(
|
let mut cmd = daemon_command(
|
||||||
&socket_path,
|
&socket_path,
|
||||||
&iface,
|
&iface,
|
||||||
@ -461,7 +406,6 @@ impl ReticulumLink {
|
|||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
display_name,
|
display_name,
|
||||||
enable_transport,
|
|
||||||
);
|
);
|
||||||
cmd.env("TMPDIR", &tmp_dir);
|
cmd.env("TMPDIR", &tmp_dir);
|
||||||
let child = cmd
|
let child = cmd
|
||||||
@ -581,28 +525,6 @@ impl ReticulumLink {
|
|||||||
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
|
|
||||||
/// destination hash. Two shapes arrive here depending on how the contact
|
|
||||||
/// record was born (observed live 2026-07-28, image-to-merged-contact):
|
|
||||||
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
|
|
||||||
/// announce) — direct `prefix_to_hash` hit;
|
|
||||||
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
|
|
||||||
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
|
|
||||||
/// no dest-hash match possible, so fall back to scanning peers whose
|
|
||||||
/// announce-bound `arch_pubkey_hex` starts with the prefix.
|
|
||||||
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
|
|
||||||
if let Some(hash) = self.prefix_to_hash.get(prefix) {
|
|
||||||
return Some(*hash);
|
|
||||||
}
|
|
||||||
let hex_prefix = hex::encode(prefix);
|
|
||||||
self.peers.values().find_map(|p| {
|
|
||||||
p.arch_pubkey_hex
|
|
||||||
.as_deref()
|
|
||||||
.filter(|arch| arch.starts_with(&hex_prefix))
|
|
||||||
.map(|_| p.dest_hash)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort sync write of the current peer table — called after any
|
/// Best-effort sync write of the current peer table — called after any
|
||||||
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
||||||
/// not per-message) so a blocking write here is a fine trade for keeping
|
/// not per-message) so a blocking write here is a fine trade for keeping
|
||||||
@ -680,7 +602,9 @@ impl ReticulumLink {
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.resolve_dest_hash(dest_pubkey_prefix)
|
.prefix_to_hash
|
||||||
|
.get(dest_pubkey_prefix)
|
||||||
|
.copied()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -722,7 +646,9 @@ impl ReticulumLink {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.resolve_dest_hash(dest_pubkey_prefix)
|
.prefix_to_hash
|
||||||
|
.get(dest_pubkey_prefix)
|
||||||
|
.copied()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -752,7 +678,9 @@ impl ReticulumLink {
|
|||||||
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.resolve_dest_hash(dest_pubkey_prefix)
|
.prefix_to_hash
|
||||||
|
.get(dest_pubkey_prefix)
|
||||||
|
.copied()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -948,15 +876,13 @@ impl ReticulumLink {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(protocol::parse_identity_broadcast);
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
let is_legacy_blob = legacy_identity.is_some();
|
let is_legacy_blob = legacy_identity.is_some();
|
||||||
let identity_blob_text =
|
let identity_blob_text = explicit_blob.or_else(|| {
|
||||||
explicit_blob.or_else(|| app_data_text.clone().filter(|_| is_legacy_blob));
|
app_data_text.clone().filter(|_| is_legacy_blob)
|
||||||
|
});
|
||||||
let parsed_identity = identity_blob_text
|
let parsed_identity = identity_blob_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(protocol::parse_identity_broadcast);
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
if let Some(text) = identity_blob_text
|
if let Some(text) = identity_blob_text.as_deref().filter(|_| parsed_identity.is_some()) {
|
||||||
.as_deref()
|
|
||||||
.filter(|_| parsed_identity.is_some())
|
|
||||||
{
|
|
||||||
let mut data = Vec::with_capacity(7 + text.len());
|
let mut data = Vec::with_capacity(7 + text.len());
|
||||||
data.push(0); // channel index — unused by the identity path
|
data.push(0); // channel index — unused by the identity path
|
||||||
data.extend_from_slice(&prefix);
|
data.extend_from_slice(&prefix);
|
||||||
@ -1097,19 +1023,10 @@ impl ReticulumLink {
|
|||||||
.push_back(build_synthetic_frame(&prefix, &content));
|
.push_back(build_synthetic_frame(&prefix, &content));
|
||||||
}
|
}
|
||||||
Some("resource_recv") => {
|
Some("resource_recv") => {
|
||||||
let source_hex = ev
|
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
||||||
.get("source_hash")
|
return;
|
||||||
.and_then(Value::as_str)
|
};
|
||||||
.unwrap_or_default();
|
|
||||||
let Ok(source_hash) = parse_hash16(source_hex) else {
|
let Ok(source_hash) = parse_hash16(source_hex) else {
|
||||||
// An empty source_hash means the sender's link wasn't
|
|
||||||
// identified (pre-identify daemon build on the far end) —
|
|
||||||
// the blob is undeliverable without attribution, but say
|
|
||||||
// so instead of vanishing it.
|
|
||||||
warn!(
|
|
||||||
source = source_hex,
|
|
||||||
"Dropping inbound Reticulum resource without valid source identity"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||||
@ -1451,10 +1368,7 @@ mod tests {
|
|||||||
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(pick_announced_name(None, Some("has\u{1}ctl".into()), false), None);
|
||||||
pick_announced_name(None, Some("has\u{1}ctl".into()), false),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
// Nothing at all.
|
// Nothing at all.
|
||||||
assert_eq!(pick_announced_name(None, None, false), None);
|
assert_eq!(pick_announced_name(None, None, false), None);
|
||||||
}
|
}
|
||||||
@ -1550,45 +1464,6 @@ mod tests {
|
|||||||
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
|
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One test covers both directions to avoid two tests racing on the same
|
|
||||||
// process-global env var under the parallel test runner.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn transport_flag_gated_on_daemon_support() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("archy-daemon-stub-{}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
|
|
||||||
let old = dir.join("old-daemon");
|
|
||||||
std::fs::write(&old, "#!/bin/sh\necho 'usage: --serial-port --no-radio'\n").unwrap();
|
|
||||||
let new = dir.join("new-daemon");
|
|
||||||
std::fs::write(
|
|
||||||
&new,
|
|
||||||
"#!/bin/sh\necho 'usage: --serial-port --enable-transport --no-radio'\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for p in [&old, &new] {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// An old daemon whose --help doesn't advertise the flag: never pass it
|
|
||||||
// (its argparse would exit and kill the mesh session — the v1.7.117
|
|
||||||
// fleet regression this guards against).
|
|
||||||
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &old);
|
|
||||||
assert!(!daemon_supports_enable_transport().await);
|
|
||||||
|
|
||||||
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &new);
|
|
||||||
assert!(daemon_supports_enable_transport().await);
|
|
||||||
|
|
||||||
// A missing binary must also fail safe (probe error → no flag).
|
|
||||||
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", dir.join("missing"));
|
|
||||||
// (falls through to the venv dev path, which doesn't exist in the
|
|
||||||
// test environment either → probe fails → false)
|
|
||||||
assert!(!daemon_supports_enable_transport().await);
|
|
||||||
|
|
||||||
std::env::remove_var("ARCHY_RETICULUM_DAEMON_BIN");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synthetic_frame_matches_meshtastic_layout() {
|
fn synthetic_frame_matches_meshtastic_layout() {
|
||||||
let prefix = [1, 2, 3, 4, 5, 6];
|
let prefix = [1, 2, 3, 4, 5, 6];
|
||||||
|
|||||||
@ -381,16 +381,21 @@ pub async fn send_to_peer(
|
|||||||
if let Some(dir) = record_data_dir {
|
if let Some(dir) = record_data_dir {
|
||||||
req = req.record_transport(dir);
|
req = req.record_transport(dir);
|
||||||
}
|
}
|
||||||
let (resp, transport) = req.send_json(&body).await.map_err(|e| {
|
let (resp, transport) = req
|
||||||
let msg = e.to_string();
|
.send_json(&body)
|
||||||
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
.await
|
||||||
anyhow::anyhow!("Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status.")
|
.map_err(|e| {
|
||||||
} else if msg.contains("timeout") || msg.contains("timed out") {
|
let msg = e.to_string();
|
||||||
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
||||||
} else {
|
anyhow::anyhow!(
|
||||||
anyhow::anyhow!("Failed to send: {}", msg)
|
"Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status."
|
||||||
}
|
)
|
||||||
})?;
|
} else if msg.contains("timeout") || msg.contains("timed out") {
|
||||||
|
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("Failed to send: {}", msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
@ -1589,24 +1589,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
if !mk.success() {
|
if !mk.success() {
|
||||||
anyhow::bail!("mkdir {} failed", staging_new);
|
anyhow::bail!("mkdir {} failed", staging_new);
|
||||||
}
|
}
|
||||||
// Idle-priority IO: extracting a 200MB tarball at normal
|
let extract =
|
||||||
// priority starved small nodes so badly during the v1.7.118
|
host_sudo(&["tar", "-xzf", &src.to_string_lossy(), "-C", &staging_new])
|
||||||
// rollout that podman/status calls timed out for minutes and
|
.await
|
||||||
// the update looked broken. The extraction can take as long
|
.with_context(|| format!("Failed to extract {}", name))?;
|
||||||
// as it likes — the node staying responsive matters more.
|
|
||||||
let extract = host_sudo(&[
|
|
||||||
"ionice",
|
|
||||||
"-c3",
|
|
||||||
"nice",
|
|
||||||
"-n10",
|
|
||||||
"tar",
|
|
||||||
"-xzf",
|
|
||||||
&src.to_string_lossy(),
|
|
||||||
"-C",
|
|
||||||
&staging_new,
|
|
||||||
])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("Failed to extract {}", name))?;
|
|
||||||
if !extract.success() {
|
if !extract.success() {
|
||||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||||
anyhow::bail!("tar extraction failed for {}", name);
|
anyhow::bail!("tar extraction failed for {}", name);
|
||||||
@ -1701,12 +1687,9 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
anyhow::bail!("mkdir {} failed", staging_new);
|
anyhow::bail!("mkdir {} failed", staging_new);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle-priority IO — same reasoning as the frontend tarball.
|
let extract = host_sudo(&["tar", "-xzf", &archive, "-C", &staging_new])
|
||||||
let extract = host_sudo(&[
|
.await
|
||||||
"ionice", "-c3", "nice", "-n10", "tar", "-xzf", &archive, "-C", &staging_new,
|
.with_context(|| format!("Failed to extract {}", name))?;
|
||||||
])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("Failed to extract {}", name))?;
|
|
||||||
if !extract.success() {
|
if !extract.success() {
|
||||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||||
anyhow::bail!("tar extraction failed for {}", name);
|
anyhow::bail!("tar extraction failed for {}", name);
|
||||||
|
|||||||
@ -1184,19 +1184,6 @@ else
|
|||||||
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
|
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware
|
|
||||||
# (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node
|
|
||||||
# imaged without it fails every RNode flash with "No such file or directory"
|
|
||||||
# (framework-pt, 2026-07-29, v1.7.117).
|
|
||||||
RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}"
|
|
||||||
if [ -f "$RNODECONF" ]; then
|
|
||||||
cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf"
|
|
||||||
chmod +x "$ARCH_DIR/bin/archy-rnodeconf"
|
|
||||||
echo " ✅ rnodeconf bundled ($(du -h "$ARCH_DIR/bin/archy-rnodeconf" | cut -f1))"
|
|
||||||
else
|
|
||||||
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
echo " ⚠️ Could not capture from live server, building from source..."
|
echo " ⚠️ Could not capture from live server, building from source..."
|
||||||
|
|||||||
@ -777,7 +777,7 @@ const SEED_MOCKDATA = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to build a static app entry
|
// Helper to build a static app entry
|
||||||
function staticApp({ id, title, version, shortDesc, longDesc, license, state, lanPort, torHost, icon, noUi }) {
|
function staticApp({ id, title, version, shortDesc, longDesc, license, state, lanPort, torHost, icon }) {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
version,
|
version,
|
||||||
@ -800,13 +800,8 @@ function staticApp({ id, title, version, shortDesc, longDesc, license, state, la
|
|||||||
'support-site': '#',
|
'support-site': '#',
|
||||||
'marketing-site': '#',
|
'marketing-site': '#',
|
||||||
'donation-url': null,
|
'donation-url': null,
|
||||||
// ui:null mirrors the real backend's ui_detection verdict for headless
|
|
||||||
// services (databases/APIs/workers): address present, no Launch button,
|
|
||||||
// files under the Services tab.
|
|
||||||
interfaces: {
|
interfaces: {
|
||||||
main: noUi
|
main: { name: 'Web Interface', description: `${title} web interface`, ui: true },
|
||||||
? { name: 'Service', description: `${title} backend service`, ui: null }
|
|
||||||
: { name: 'Web Interface', description: `${title} web interface`, ui: true },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
installed: {
|
installed: {
|
||||||
@ -896,28 +891,6 @@ const staticDevApps = {
|
|||||||
lanPort: 4403,
|
lanPort: 4403,
|
||||||
icon: '/assets/img/app-icons/meshcore.svg',
|
icon: '/assets/img/app-icons/meshcore.svg',
|
||||||
}),
|
}),
|
||||||
// Self-deployed compose stack (v1.7.117+ services classification demo):
|
|
||||||
// the main app serves a real web page → launchable; its media backend has
|
|
||||||
// an exposed port but no UI → Services tab, no Launch button.
|
|
||||||
podsteadr: staticApp({
|
|
||||||
id: 'podsteadr',
|
|
||||||
title: 'Podsteadr',
|
|
||||||
version: '0.4.1',
|
|
||||||
shortDesc: 'Self-hosted podcast studio',
|
|
||||||
longDesc: 'Record, host, and publish podcasts from your own node — deployed by hand outside the app store to demo third-party stacks.',
|
|
||||||
state: 'running',
|
|
||||||
lanPort: 8095,
|
|
||||||
}),
|
|
||||||
'podsteadr-mediamtx': staticApp({
|
|
||||||
id: 'podsteadr-mediamtx',
|
|
||||||
title: 'Podsteadr MediaMTX',
|
|
||||||
version: '1.9.3',
|
|
||||||
shortDesc: 'RTMP/WebRTC media backend',
|
|
||||||
longDesc: 'Streaming backend for Podsteadr. Speaks RTMP and WebRTC on exposed ports — no web page of its own.',
|
|
||||||
state: 'running',
|
|
||||||
lanPort: 8889,
|
|
||||||
noUi: true,
|
|
||||||
}),
|
|
||||||
filebrowser: staticApp({
|
filebrowser: staticApp({
|
||||||
id: 'filebrowser',
|
id: 'filebrowser',
|
||||||
title: 'File Browser',
|
title: 'File Browser',
|
||||||
@ -2988,22 +2961,10 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
channel_name: mc.channel_name,
|
channel_name: mc.channel_name,
|
||||||
messages_sent: 23,
|
messages_sent: 23,
|
||||||
messages_received: 47,
|
messages_received: 47,
|
||||||
// Demo device-detection: ~8s into the session a second, freshly
|
detected_devices: ['/dev/ttyUSB0'],
|
||||||
// "plugged-in" radio appears on /dev/ttyACM0. The frontend's
|
|
||||||
// 2-sighting debounce then pops the global setup modal a few
|
|
||||||
// seconds later. plugged_at is FIXED so dismissing it ("Not now")
|
|
||||||
// sticks for the whole browser session — the modal shows once.
|
|
||||||
detected_devices: (() => {
|
|
||||||
mockState.meshFirstStatusAt ||= Date.now()
|
|
||||||
const demoPlug = Date.now() - mockState.meshFirstStatusAt > 8000
|
|
||||||
return demoPlug ? ['/dev/ttyUSB0', '/dev/ttyACM0'] : ['/dev/ttyUSB0']
|
|
||||||
})(),
|
|
||||||
device_present: true,
|
device_present: true,
|
||||||
detected_device_info: [
|
detected_device_info: [
|
||||||
{ path: '/dev/ttyUSB0', vid: '10c4', pid: 'ea60', product: 'HELTEC LoRa 32 V3', manufacturer: 'Heltec', plugged_at: 1753790000 },
|
{ path: '/dev/ttyUSB0', vid: '10c4', pid: 'ea60', product: 'HELTEC LoRa 32 V3', manufacturer: 'Heltec' },
|
||||||
...(Date.now() - (mockState.meshFirstStatusAt || Date.now()) > 8000
|
|
||||||
? [{ path: '/dev/ttyACM0', vid: '303a', pid: '1001', product: 'Heltec V4 (RNode)', manufacturer: 'Espressif', plugged_at: 1753790001 }]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
lora_region: mc.lora_region,
|
lora_region: mc.lora_region,
|
||||||
device_kind: mc.device_kind,
|
device_kind: mc.device_kind,
|
||||||
@ -3069,18 +3030,18 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
const limit = params?.limit || 100
|
const limit = params?.limit || 100
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const allMessages = [
|
const allMessages = [
|
||||||
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true, transport: 'meshcore', sender_pubkey: 'demo01abababababababababababababababababababababababababababab', sender_seq: 1, message_type: 'text' },
|
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true, transport: 'meshcore', sender_pubkey: 'demo02abababababababababababababababababababababababababababab', sender_seq: 2, message_type: 'text' },
|
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'Block #890,413 relayed. Fees avg 12 sat/vB.', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: true, transport: 'reticulum', sender_pubkey: 'demo03abababababababababababababababababababababababababababab', sender_seq: 3, message_type: 'block_header', typed_payload: { alert_type: 'block_header', message: 'Block #890,413 — 2,847 txs, 12 sat/vB avg fee', signed: true } },
|
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'Block #890,413 relayed. Fees avg 12 sat/vB.', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: true, message_type: 'block_header', typed_payload: { alert_type: 'block_header', message: 'Block #890,413 — 2,847 txs, 12 sat/vB avg fee', signed: true } },
|
||||||
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice: 50,000 sats — Channel opening fee', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true, transport: 'fips', sender_pubkey: 'demo04abababababababababababababababababababababababababababab', sender_seq: 4, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: false } },
|
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice: 50,000 sats — Channel opening fee', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: false } },
|
||||||
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true, transport: 'reticulum', sender_pubkey: 'demo05abababababababababababababababababababababababababababab', sender_seq: 5, message_type: 'text' },
|
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true, transport: 'reticulum', sender_pubkey: 'demo06abababababababababababababababababababababababababababab', sender_seq: 6, message_type: 'text' },
|
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 7, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'EMERGENCY: Solar array failure. Running on battery reserve.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: false, transport: 'meshcore', sender_pubkey: 'demo07abababababababababababababababababababababababababababab', sender_seq: 7, message_type: 'alert', typed_payload: { alert_type: 'emergency', message: 'Solar array failure. Running on battery reserve. ETA 4h before shutdown.', coordinate: { lat: 39507400, lng: -106042800, label: 'Mountain relay site' }, signed: true } },
|
{ id: 7, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'EMERGENCY: Solar array failure. Running on battery reserve.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: false, message_type: 'alert', typed_payload: { alert_type: 'emergency', message: 'Solar array failure. Running on battery reserve. ETA 4h before shutdown.', coordinate: { lat: 39507400, lng: -106042800, label: 'Mountain relay site' }, signed: true } },
|
||||||
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true, transport: 'fips', sender_pubkey: 'demo08abababababababababababababababababababababababababababab', sender_seq: 8, message_type: 'text' },
|
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true, transport: 'tor', sender_pubkey: 'demo09abababababababababababababababababababababababababababab', sender_seq: 9, message_type: 'text' },
|
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 10, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Location shared', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false, transport: 'meshcore', sender_pubkey: 'demo10abababababababababababababababababababababababababababab', sender_seq: 10, message_type: 'coordinate', typed_payload: { lat: 30267200, lng: -97743100, label: 'Supply drop point' } },
|
{ id: 10, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Location shared', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false, message_type: 'coordinate', typed_payload: { lat: 30267200, lng: -97743100, label: 'Supply drop point' } },
|
||||||
{ id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, transport: 'reticulum', sender_pubkey: 'demo11abababababababababababababababababababababababababababab', sender_seq: 11, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } },
|
{ id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } },
|
||||||
{ id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, transport: 'fips', sender_pubkey: 'demo12abababababababababababababababababababababababababababab', sender_seq: 12, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } },
|
{ id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } },
|
||||||
]
|
]
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
@ -4282,101 +4243,6 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
return res.json({ result: { rejected: true, id: params?.id || '' } })
|
return res.json({ result: { rejected: true, id: params?.id || '' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mesh chat polish + flash flow (v1.7.117/118 demo coverage) ──────
|
|
||||||
|
|
||||||
// Image-modal "Send via" pills: most demo peers are federation-reachable
|
|
||||||
// (LoRa + FIPS + Tor pills all offered); mountain-node (3) stays
|
|
||||||
// radio-only to show the contrast.
|
|
||||||
case 'mesh.transport-advice': {
|
|
||||||
const federated = params?.contact_id !== 3
|
|
||||||
return res.json({
|
|
||||||
result: {
|
|
||||||
tier: federated ? 'choose' : 'auto-mesh',
|
|
||||||
est_seconds: federated ? 4 : 90,
|
|
||||||
has_tor: federated,
|
|
||||||
has_fips: federated,
|
|
||||||
last_transport: federated ? 'fips' : null,
|
|
||||||
reason: federated
|
|
||||||
? 'Peer reachable over the FIPS overlay — radio optional'
|
|
||||||
: 'Radio-only peer',
|
|
||||||
size: params?.size || 0,
|
|
||||||
mesh_auto_max: 24576,
|
|
||||||
mesh_hard_max: 262144,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'mesh.flash-list-firmware': {
|
|
||||||
const versions = {
|
|
||||||
meshcore: ['v1.7.4', 'v1.7.2', 'v1.6.9'],
|
|
||||||
meshtastic: ['2.5.15', '2.5.13', '2.4.2'],
|
|
||||||
reticulum: ['latest (rnodeconf autoinstall)'],
|
|
||||||
}
|
|
||||||
return res.json({ result: { versions: versions[params?.family] || [] } })
|
|
||||||
}
|
|
||||||
case 'mesh.flash-device': {
|
|
||||||
mockState.flashJob = {
|
|
||||||
startedAt: Date.now(),
|
|
||||||
board: params?.board || 'heltec-v3',
|
|
||||||
family: params?.family || 'meshcore',
|
|
||||||
path: params?.path || '/dev/ttyUSB0',
|
|
||||||
cancelled: false,
|
|
||||||
}
|
|
||||||
return res.json({ result: { started: true } })
|
|
||||||
}
|
|
||||||
case 'mesh.flash-status': {
|
|
||||||
const job = mockState.flashJob
|
|
||||||
if (!job) return res.json({ result: { active: false, done: true, log_tail: [] } })
|
|
||||||
const t = (Date.now() - job.startedAt) / 1000
|
|
||||||
// Scripted 35s job: download → erase → write → done.
|
|
||||||
let stage = 'downloading'
|
|
||||||
let percent = Math.min(100, Math.round((t / 8) * 100))
|
|
||||||
if (job.cancelled) stage = 'failed'
|
|
||||||
else if (t > 30) { stage = 'done'; percent = 100 }
|
|
||||||
else if (t > 14) { stage = 'writing'; percent = Math.min(100, Math.round(((t - 14) / 16) * 100)) }
|
|
||||||
else if (t > 8) { stage = 'erasing'; percent = null }
|
|
||||||
const log = [
|
|
||||||
`Fetching ${job.family} firmware for ${job.board}...`,
|
|
||||||
...(t > 4 ? ['Downloaded 2.1 MB firmware image'] : []),
|
|
||||||
...(t > 8 ? ['esptool erase_flash: chip erase completed'] : []),
|
|
||||||
...(t > 14 ? [`Writing at 0x00010000... (${percent || 0} %)`] : []),
|
|
||||||
...(stage === 'done' ? ['Hash of data verified.', 'Hard resetting via RTS pin...'] : []),
|
|
||||||
...(job.cancelled ? ['ERROR: flash cancelled by user'] : []),
|
|
||||||
]
|
|
||||||
const active = stage !== 'done' && stage !== 'failed'
|
|
||||||
if (!active) mockState.flashJob = active ? job : job // keep for final poll
|
|
||||||
return res.json({
|
|
||||||
result: {
|
|
||||||
active,
|
|
||||||
board: job.board,
|
|
||||||
family: job.family,
|
|
||||||
path: job.path,
|
|
||||||
stage,
|
|
||||||
percent,
|
|
||||||
log_tail: log,
|
|
||||||
done: !active,
|
|
||||||
error: job.cancelled ? 'Flash cancelled' : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case 'mesh.flash-cancel': {
|
|
||||||
if (mockState.flashJob) mockState.flashJob.cancelled = true
|
|
||||||
return res.json({ result: { cancelled: true } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat actions the demo only needs to acknowledge.
|
|
||||||
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 } })
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
console.log(`[RPC] Unknown method: ${method}`)
|
console.log(`[RPC] Unknown method: ${method}`)
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
4
neode-ui/package-lock.json
generated
4
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.118-alpha",
|
"version": "1.7.115-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.118-alpha",
|
"version": "1.7.115-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.118-alpha",
|
"version": "1.7.116-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@ -445,7 +445,7 @@ class RPCClient {
|
|||||||
async payLightningInvoice(params: {
|
async payLightningInvoice(params: {
|
||||||
payment_request: string
|
payment_request: string
|
||||||
amount_sats?: number
|
amount_sats?: number
|
||||||
}, onPending?: (payment_hash: string) => void): Promise<{
|
}): Promise<{
|
||||||
status: 'succeeded' | 'failed' | 'pending'
|
status: 'succeeded' | 'failed' | 'pending'
|
||||||
payment_hash: string
|
payment_hash: string
|
||||||
amount_sats: number
|
amount_sats: number
|
||||||
@ -470,11 +470,6 @@ class RPCClient {
|
|||||||
}
|
}
|
||||||
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
|
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
|
||||||
|
|
||||||
// Let the caller unblock its UI right now ("settling…") — the backend
|
|
||||||
// answers pending after ~8s, and freezing a modal through the poll loop
|
|
||||||
// below was the "paid but the interface stalled" report.
|
|
||||||
try { onPending?.(hash) } catch { /* caller UI must not break polling */ }
|
|
||||||
|
|
||||||
// Poll to a terminal state: every 3s for up to 2 minutes.
|
// Poll to a terminal state: every 3s for up to 2 minutes.
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
await new Promise((r) => setTimeout(r, 3000))
|
await new Promise((r) => setTimeout(r, 3000))
|
||||||
|
|||||||
@ -621,28 +621,9 @@ async function send() {
|
|||||||
} else if (method === 'lightning') {
|
} else if (method === 'lightning') {
|
||||||
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
||||||
// Waits out slow multi-hop routing and only reports failure when LND
|
// Waits out slow multi-hop routing and only reports failure when LND
|
||||||
// itself declares the payment failed — never on a timeout. The moment
|
// itself declares the payment failed — never on a timeout.
|
||||||
// the payment goes pending (~8s), the success pane appears in
|
const res = await rpcClient.payLightningInvoice({ payment_request: dest.value.trim() })
|
||||||
// "settling…" mode instead of freezing the modal through the poll —
|
|
||||||
// the poll keeps running and upgrades the pane when LND settles.
|
|
||||||
const res = await rpcClient.payLightningInvoice(
|
|
||||||
{ payment_request: dest.value.trim() },
|
|
||||||
(hash) => {
|
|
||||||
successInfo.value = {
|
|
||||||
amount: paidAmount,
|
|
||||||
methodLabel: 'Settling…',
|
|
||||||
hash,
|
|
||||||
note: 'Payment is on its way through the network. This pane updates the moment it settles — safe to close.',
|
|
||||||
}
|
|
||||||
confirming.value = false
|
|
||||||
processing.value = false
|
|
||||||
emit('sent')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (res.status === 'failed') {
|
if (res.status === 'failed') {
|
||||||
// If the settling pane is up, replace it with the failure — LND
|
|
||||||
// declared this payment failed for real.
|
|
||||||
successInfo.value = null
|
|
||||||
error.value = res.failure_reason || t('web5.sendFailed')
|
error.value = res.failure_reason || t('web5.sendFailed')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,8 @@
|
|||||||
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
||||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
||||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||||
on-chain/Lightning rows; chips keep the standard txs reachable.
|
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||||
Sticky inside the modal's scroll region so the tabs stay pinned
|
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||||
while rows blur past underneath. -->
|
|
||||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-black/70 backdrop-blur-md">
|
|
||||||
<button
|
<button
|
||||||
v-for="f in filters"
|
v-for="f in filters"
|
||||||
:key="f.key"
|
:key="f.key"
|
||||||
@ -28,12 +26,7 @@
|
|||||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Must NOT be its own scroll container: BaseModal's slot wrapper is the
|
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
||||||
scroller, and modal-scroll-locked gives every .overflow-y-auto inside
|
|
||||||
the overlay overscroll-behavior:contain — a nested scroller with no
|
|
||||||
overflow then swallows touch scrolling entirely (phones only; wheel
|
|
||||||
latches onto the scrollable ancestor and never sees the bug). -->
|
|
||||||
<div v-else class="-mx-2 px-2 divide-y divide-white/5">
|
|
||||||
<div
|
<div
|
||||||
v-for="tx in filteredTransactions"
|
v-for="tx in filteredTransactions"
|
||||||
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
||||||
|
|||||||
@ -704,18 +704,8 @@ async function confirmSend() {
|
|||||||
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
|
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
|
||||||
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
||||||
// Waits out slow multi-hop routing and only reports failure when LND
|
// Waits out slow multi-hop routing and only reports failure when LND
|
||||||
// itself declares the payment failed — never on a timeout. Pending
|
// itself declares the payment failed — never on a timeout.
|
||||||
// (~8s) jumps straight to the success pane in SETTLING mode instead of
|
const res = await rpcClient.payLightningInvoice(params)
|
||||||
// spinning through the whole poll; the poll upgrades it to PAID.
|
|
||||||
const res = await rpcClient.payLightningInvoice(params, (hash) => {
|
|
||||||
successAmount.value = effectiveAmount.value
|
|
||||||
successVerb.value = 'SETTLING'
|
|
||||||
successDetail.value = 'Payment is on its way — this updates the moment it settles. Safe to close.'
|
|
||||||
successRef.value = hash
|
|
||||||
processing.value = false
|
|
||||||
goTo('success')
|
|
||||||
emit('sent')
|
|
||||||
})
|
|
||||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||||
successAmount.value = res.amount_sats || effectiveAmount.value
|
successAmount.value = res.amount_sats || effectiveAmount.value
|
||||||
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
|
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
|
||||||
|
|||||||
@ -340,7 +340,7 @@ function stopProbeProgress(done = false) {
|
|||||||
if (done) probeProgress.value = 100
|
if (done) probeProgress.value = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
|
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||||
const show = computed(() => !!devicePath.value)
|
const show = computed(() => !!devicePath.value)
|
||||||
const imageFailed = ref(false)
|
const imageFailed = ref(false)
|
||||||
const deviceImage = computed(() =>
|
const deviceImage = computed(() =>
|
||||||
@ -402,23 +402,6 @@ watch([show, devicePath], async ([visible]) => {
|
|||||||
stopFlashPoll()
|
stopFlashPoll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mesh.flashFlowPath) {
|
|
||||||
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
|
|
||||||
// read-only probe — the port is usually the live session's, and a second
|
|
||||||
// opener on the tty corrupts the running connection; status already
|
|
||||||
// knows the firmware kind for the connected radio.
|
|
||||||
probe.value = null
|
|
||||||
probeError.value = ''
|
|
||||||
probing.value = false
|
|
||||||
stopProbeProgress()
|
|
||||||
imageFailed.value = false
|
|
||||||
openFlashStep()
|
|
||||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
|
||||||
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
|
|
||||||
flashFamily.value = t as FlashFirmwareFamily
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
step.value = 1
|
step.value = 1
|
||||||
error.value = ''
|
error.value = ''
|
||||||
imageFailed.value = false
|
imageFailed.value = false
|
||||||
@ -445,10 +428,6 @@ watch([show, devicePath], async ([visible]) => {
|
|||||||
}, { immediate: false })
|
}, { immediate: false })
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
if (mesh.flashFlowPath) {
|
|
||||||
mesh.closeFlashFlow()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
|
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -619,11 +598,6 @@ async function cancelFlash() {
|
|||||||
|
|
||||||
function closeFlashStep() {
|
function closeFlashStep() {
|
||||||
stopFlashPoll()
|
stopFlashPoll()
|
||||||
if (mesh.flashFlowPath) {
|
|
||||||
// Manual entry has no detection step to go back to — close the modal.
|
|
||||||
mesh.closeFlashFlow()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
step.value = 1
|
step.value = 1
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -706,8 +706,6 @@
|
|||||||
"downloadFailed": "Download failed. Please try again.",
|
"downloadFailed": "Download failed. Please try again.",
|
||||||
"applySuccess": "Update applied. The service will restart momentarily.",
|
"applySuccess": "Update applied. The service will restart momentarily.",
|
||||||
"applyFailed": "Failed to apply update. You can try again or rollback.",
|
"applyFailed": "Failed to apply update. You can try again or rollback.",
|
||||||
"applyInProgress": "An update is already being installed — hang tight, the node will restart when it finishes.",
|
|
||||||
"downloadInProgress": "An update download is already in progress — progress will appear above.",
|
|
||||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||||
"rollbackFailed": "Rollback failed.",
|
"rollbackFailed": "Rollback failed.",
|
||||||
"pullAndRebuild": "Pull & Rebuild",
|
"pullAndRebuild": "Pull & Rebuild",
|
||||||
|
|||||||
@ -693,8 +693,6 @@
|
|||||||
"downloadFailed": "La descarga fall\u00f3. Intente de nuevo.",
|
"downloadFailed": "La descarga fall\u00f3. Intente de nuevo.",
|
||||||
"applySuccess": "Actualizaci\u00f3n aplicada. El servicio se reiniciar\u00e1 en un momento.",
|
"applySuccess": "Actualizaci\u00f3n aplicada. El servicio se reiniciar\u00e1 en un momento.",
|
||||||
"applyFailed": "Error al aplicar la actualizaci\u00f3n. Puede intentar de nuevo o revertir.",
|
"applyFailed": "Error al aplicar la actualizaci\u00f3n. Puede intentar de nuevo o revertir.",
|
||||||
"applyInProgress": "Ya se est\u00e1 instalando una actualizaci\u00f3n \u2014 espere, el nodo se reiniciar\u00e1 al terminar.",
|
|
||||||
"downloadInProgress": "Ya hay una descarga de actualizaci\u00f3n en curso \u2014 el progreso aparecer\u00e1 arriba.",
|
|
||||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||||
"rollbackFailed": "Error al revertir.",
|
"rollbackFailed": "Error al revertir.",
|
||||||
"pullAndRebuild": "Pull y Recompilar",
|
"pullAndRebuild": "Pull y Recompilar",
|
||||||
|
|||||||
@ -281,35 +281,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
|
|
||||||
// Track unread message counts per peer (contact_id -> count)
|
// Track unread message counts per peer (contact_id -> count)
|
||||||
const unreadCounts = ref<Record<number, number>>({})
|
const unreadCounts = ref<Record<number, number>>({})
|
||||||
// Durable seen-state: highest message id the user has actually seen, per
|
// Currently viewing chat for this contact_id (clears unread)
|
||||||
// contact. Without this, every page load replayed ALL historical received
|
const viewingChatId = ref<number | null>(null)
|
||||||
// messages into unreadCounts (the in-memory store starts empty, so every
|
|
||||||
// old message looked "new") — badges came back on every visit. Message ids
|
|
||||||
// are safe watermarks: the backend allocates them monotonically and
|
|
||||||
// restores the counter as max(persisted)+1 across restarts.
|
|
||||||
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
|
|
||||||
const lastSeenId = ref<Record<number, number>>(
|
|
||||||
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
|
|
||||||
)
|
|
||||||
// First run after this feature ships: treat existing history as seen so
|
|
||||||
// nobody gets a wall of phantom badges for months-old messages.
|
|
||||||
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
|
|
||||||
function persistLastSeen() {
|
|
||||||
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
|
|
||||||
}
|
|
||||||
function advanceLastSeen(contactId: number, msgId: number): boolean {
|
|
||||||
if ((lastSeenId.value[contactId] ?? 0) >= msgId) return false
|
|
||||||
lastSeenId.value[contactId] = msgId
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Contact ids of the chat currently on screen — ALL twins of the merged
|
|
||||||
// conversation, not just the clicked row's id, since the unread badge sums
|
|
||||||
// across every underlying contact_id (a message can land on any twin).
|
|
||||||
const viewingChatIds = ref<number[]>([])
|
|
||||||
// Whether the open chat is scrolled to (near) the bottom. New messages only
|
|
||||||
// auto-clear as read while the latest messages are actually in view —
|
|
||||||
// standard chat-app semantics; scrolled-up-in-history still counts unread.
|
|
||||||
const viewingAtBottom = ref(true)
|
|
||||||
// Total unread count for nav badge
|
// Total unread count for nav badge
|
||||||
const totalUnread = computed(() =>
|
const totalUnread = computed(() =>
|
||||||
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
||||||
@ -398,16 +371,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
}
|
}
|
||||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||||
}
|
}
|
||||||
// Manual "Flash LoRa" entry (Mesh header button): forces the global setup
|
|
||||||
// modal open at the flash step for this port — including the live session's
|
|
||||||
// port, which detection-driven opening deliberately excludes.
|
|
||||||
const flashFlowPath = ref<string | null>(null)
|
|
||||||
function openFlashFlow(path: string) {
|
|
||||||
flashFlowPath.value = path
|
|
||||||
}
|
|
||||||
function closeFlashFlow() {
|
|
||||||
flashFlowPath.value = null
|
|
||||||
}
|
|
||||||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||||||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||||||
return rpcClient.call<MeshDeviceProbe>({
|
return rpcClient.call<MeshDeviceProbe>({
|
||||||
@ -476,35 +439,16 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
method: 'mesh.messages',
|
method: 'mesh.messages',
|
||||||
params: limit ? { limit } : {},
|
params: limit ? { limit } : {},
|
||||||
})
|
})
|
||||||
if (seedLastSeenFromHistory && res.messages.length > 0) {
|
// Detect new incoming messages and increment unread counts
|
||||||
for (const m of res.messages) {
|
|
||||||
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
|
|
||||||
}
|
|
||||||
persistLastSeen()
|
|
||||||
seedLastSeenFromHistory = false
|
|
||||||
}
|
|
||||||
// Detect new incoming messages and increment unread counts. "New" means
|
|
||||||
// past the durable seen watermark, not merely absent from the in-memory
|
|
||||||
// store — otherwise a page reload re-badges the whole history.
|
|
||||||
const newMsgs = res.messages.filter(
|
const newMsgs = res.messages.filter(
|
||||||
m =>
|
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
|
||||||
m.direction === 'received' &&
|
|
||||||
m.id > (lastSeenId.value[m.peer_contact_id] ?? 0) &&
|
|
||||||
!messages.value.some(existing => existing.id === m.id)
|
|
||||||
)
|
)
|
||||||
let seenDirty = false
|
|
||||||
for (const msg of newMsgs) {
|
for (const msg of newMsgs) {
|
||||||
// Don't count as unread if we're currently viewing that chat AND the
|
// Don't count as unread if we're currently viewing that chat
|
||||||
// bottom (latest messages) is in view — i.e. the user actually sees it.
|
if (msg.peer_contact_id !== viewingChatId.value) {
|
||||||
const seenLive =
|
|
||||||
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
|
|
||||||
if (seenLive) {
|
|
||||||
seenDirty = advanceLastSeen(msg.peer_contact_id, msg.id) || seenDirty
|
|
||||||
} else {
|
|
||||||
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seenDirty) persistLastSeen()
|
|
||||||
messages.value = res.messages
|
messages.value = res.messages
|
||||||
// Extract node positions from coordinate messages
|
// Extract node positions from coordinate messages
|
||||||
updateNodePositionsFromMessages(res.messages)
|
updateNodePositionsFromMessages(res.messages)
|
||||||
@ -629,25 +573,13 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
federatedPositions.value = next
|
federatedPositions.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function markChatRead(contactId: number | number[]) {
|
function markChatRead(contactId: number) {
|
||||||
const ids = Array.isArray(contactId) ? contactId : [contactId]
|
viewingChatId.value = contactId
|
||||||
viewingChatIds.value = ids
|
delete unreadCounts.value[contactId]
|
||||||
let seenDirty = false
|
|
||||||
for (const id of ids) {
|
|
||||||
delete unreadCounts.value[id]
|
|
||||||
// Persist the watermark so the seen-state survives reloads.
|
|
||||||
for (const m of messages.value) {
|
|
||||||
if (m.direction === 'received' && m.peer_contact_id === id) {
|
|
||||||
seenDirty = advanceLastSeen(id, m.id) || seenDirty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (seenDirty) persistLastSeen()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearViewingChat() {
|
function clearViewingChat() {
|
||||||
viewingChatIds.value = []
|
viewingChatId.value = null
|
||||||
viewingAtBottom.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(contactId: number, message: string) {
|
async function sendMessage(contactId: number, message: string) {
|
||||||
@ -792,8 +724,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
||||||
est_seconds: number
|
est_seconds: number
|
||||||
has_tor: boolean
|
has_tor: boolean
|
||||||
has_fips: boolean
|
|
||||||
last_transport: string | null
|
|
||||||
reason: string
|
reason: string
|
||||||
size: number
|
size: number
|
||||||
mesh_auto_max: number
|
mesh_auto_max: number
|
||||||
@ -1112,7 +1042,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
error,
|
error,
|
||||||
sending,
|
sending,
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
viewingAtBottom,
|
|
||||||
totalUnread,
|
totalUnread,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
federatedPositions,
|
federatedPositions,
|
||||||
@ -1123,9 +1052,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
fetchStatus,
|
fetchStatus,
|
||||||
undismissedDetectedDevices,
|
undismissedDetectedDevices,
|
||||||
dismissDetectedDevice,
|
dismissDetectedDevice,
|
||||||
flashFlowPath,
|
|
||||||
openFlashFlow,
|
|
||||||
closeFlashFlow,
|
|
||||||
probeDevice,
|
probeDevice,
|
||||||
flashListFirmware,
|
flashListFirmware,
|
||||||
flashDevice,
|
flashDevice,
|
||||||
|
|||||||
@ -547,7 +547,6 @@ const showScanModal = ref(false); const showSendModal = ref(false); const showRe
|
|||||||
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
||||||
|
|
||||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
||||||
let walletInfoFailures = 0
|
|
||||||
const walletArk = ref(0)
|
const walletArk = ref(0)
|
||||||
const walletTransactions = ref<WalletTransaction[]>([])
|
const walletTransactions = ref<WalletTransaction[]>([])
|
||||||
|
|
||||||
@ -631,16 +630,8 @@ async function loadWeb5Status() {
|
|||||||
// call, which is what makes the card feel like an app launch.
|
// call, which is what makes the card feel like an app launch.
|
||||||
const balances = Promise.allSettled([
|
const balances = Promise.allSettled([
|
||||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true })
|
||||||
.catch(() => {
|
.catch(() => { walletConnected.value = false }),
|
||||||
// A single slow poll must NOT flip the card to "disconnected" and
|
|
||||||
// hide balances the user already knows — busy nodes routinely blow
|
|
||||||
// the 5s budget mid-payment or during IO storms (framework-pt user
|
|
||||||
// report: balances vanished while a payment settled). Only call it
|
|
||||||
// disconnected after three consecutive failures (~30s of silence).
|
|
||||||
walletInfoFailures += 1
|
|
||||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
|
||||||
}),
|
|
||||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
|
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
|
||||||
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
|
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
|
||||||
|
|||||||
@ -21,18 +21,6 @@ const mesh = useMeshStore()
|
|||||||
const transport = useTransportStore()
|
const transport = useTransportStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// "Flash LoRa" header button target: the connected radio wins, else any
|
|
||||||
// detected-but-unconnected stick. Opens the global setup modal at its
|
|
||||||
// flash step (backend stops the listener and frees the port itself).
|
|
||||||
const flashLoraPath = computed(() =>
|
|
||||||
(mesh.status?.device_connected && mesh.status.device_path) ||
|
|
||||||
mesh.status?.detected_devices?.[0] ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
function openFlashLora() {
|
|
||||||
if (flashLoraPath.value) mesh.openFlashFlow(flashLoraPath.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive layout breakpoints
|
// Responsive layout breakpoints
|
||||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||||
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
||||||
@ -908,14 +896,8 @@ function openChat(peer: MeshPeer) {
|
|||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
mobileShowChat.value = true
|
mobileShowChat.value = true
|
||||||
nextTick(() => {
|
mesh.markChatRead(peer.contact_id)
|
||||||
// Clear unread only after the chat has rendered and auto-scrolled to the
|
nextTick(() => scrollChatToBottom())
|
||||||
// latest messages ("you've actually seen them"), and clear ALL twins of
|
|
||||||
// the merged conversation — the badge sums across every contact_id, so
|
|
||||||
// clearing just the clicked row's id left the badge stuck.
|
|
||||||
scrollChatToBottom()
|
|
||||||
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChannelChat(channel: { index: number; name: string }) {
|
function openChannelChat(channel: { index: number; name: string }) {
|
||||||
@ -1027,25 +1009,9 @@ async function handleSendMessage() {
|
|||||||
function scrollChatToBottom() {
|
function scrollChatToBottom() {
|
||||||
if (chatScrollEl.value) {
|
if (chatScrollEl.value) {
|
||||||
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
|
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
|
||||||
mesh.viewingAtBottom = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll-position read tracking: while the latest messages are in view the
|
|
||||||
// open conversation stays "read" (new arrivals don't badge); scrolled up into
|
|
||||||
// history, incoming messages accumulate unread like any normal chat app —
|
|
||||||
// scrolling back down clears them.
|
|
||||||
function onChatScroll() {
|
|
||||||
const el = chatScrollEl.value
|
|
||||||
if (!el) return
|
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
|
||||||
mesh.viewingAtBottom = nearBottom
|
|
||||||
if (nearBottom && activeMergedPeer.value) {
|
|
||||||
mesh.markChatRead(activeMergedPeer.value.contact_ids)
|
|
||||||
}
|
|
||||||
scheduleReadReceipt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the compose field focused after a send so the user can keep typing
|
// Keep the compose field focused after a send so the user can keep typing
|
||||||
// without re-clicking it (Enter re-focuses natively, but the Send button
|
// without re-clicking it (Enter re-focuses natively, but the Send button
|
||||||
// click otherwise leaves focus on the button).
|
// click otherwise leaves focus on the button).
|
||||||
@ -1170,15 +1136,14 @@ interface PendingReply {
|
|||||||
}
|
}
|
||||||
const pendingReply = ref<PendingReply | null>(null)
|
const pendingReply = ref<PendingReply | null>(null)
|
||||||
const actionMenuForId = ref<number | null>(null)
|
const actionMenuForId = ref<number | null>(null)
|
||||||
|
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||||
|
|
||||||
function openActionMenu(msgId: number, ev?: Event) {
|
function openActionMenu(msgId: number, ev?: Event) {
|
||||||
ev?.stopPropagation()
|
ev?.stopPropagation()
|
||||||
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
|
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
|
||||||
reactionPickerForId.value = null
|
|
||||||
}
|
}
|
||||||
function closeActionMenu() {
|
function closeActionMenu() {
|
||||||
actionMenuForId.value = null
|
actionMenuForId.value = null
|
||||||
reactionPickerForId.value = null
|
|
||||||
}
|
}
|
||||||
function handleDocClickForMenu(ev: MouseEvent) {
|
function handleDocClickForMenu(ev: MouseEvent) {
|
||||||
if (actionMenuForId.value === null) return
|
if (actionMenuForId.value === null) return
|
||||||
@ -1288,43 +1253,6 @@ function scheduleReadReceipt() {
|
|||||||
}, 400)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hop visualization modal — click a message's transport pill to see how it
|
|
||||||
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
|
|
||||||
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
|
|
||||||
// not stored per-message) — labelled as such in the modal.
|
|
||||||
const hopVizMsg = ref<MeshMessage | null>(null)
|
|
||||||
const hopVizPeer = computed<MeshPeer | null>(() => {
|
|
||||||
const m = hopVizMsg.value
|
|
||||||
if (!m) return null
|
|
||||||
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
|
|
||||||
})
|
|
||||||
function hopVizHops(): number | null {
|
|
||||||
const p = hopVizPeer.value
|
|
||||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
|
||||||
return p.hops
|
|
||||||
}
|
|
||||||
function signalQualityLabel(snr: number | null, rssi: number | null): string {
|
|
||||||
if (snr == null && rssi == null) return 'signal unknown'
|
|
||||||
if (snr != null) {
|
|
||||||
if (snr > 5) return 'excellent signal'
|
|
||||||
if (snr > 0) return 'good signal'
|
|
||||||
if (snr > -10) return 'fair signal'
|
|
||||||
return 'weak signal'
|
|
||||||
}
|
|
||||||
if (rssi != null) {
|
|
||||||
if (rssi > -80) return 'excellent signal'
|
|
||||||
if (rssi > -100) return 'good signal'
|
|
||||||
if (rssi > -115) return 'fair signal'
|
|
||||||
return 'weak signal'
|
|
||||||
}
|
|
||||||
return 'signal unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
|
|
||||||
// the action menu stays compact and a larger emoji palette fits.
|
|
||||||
const reactionPickerForId = ref<number | null>(null)
|
|
||||||
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
|
|
||||||
|
|
||||||
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
|
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
|
||||||
async function reactTo(msg: MeshMessage, emoji: string) {
|
async function reactTo(msg: MeshMessage, emoji: string) {
|
||||||
const key = messageKeyFor(msg)
|
const key = messageKeyFor(msg)
|
||||||
@ -1512,14 +1440,6 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
|
|||||||
const imageQualityChoice = ref<{ file: File } | null>(null)
|
const imageQualityChoice = ref<{ file: File } | null>(null)
|
||||||
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
||||||
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
||||||
// Transport pills at the bottom of the image modal: when the peer is also a
|
|
||||||
// federation node (FIPS overlay and/or Tor), the user can send the image that
|
|
||||||
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
|
|
||||||
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
|
|
||||||
// fallback — the pills are capability labels, defaulted to whichever of the
|
|
||||||
// two actually worked last for this peer).
|
|
||||||
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
|
|
||||||
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
|
|
||||||
|
|
||||||
function formatEstSeconds(seconds: number): string {
|
function formatEstSeconds(seconds: number): string {
|
||||||
if (seconds < 60) return `~${seconds}s`
|
if (seconds < 60) return `~${seconds}s`
|
||||||
@ -1529,8 +1449,6 @@ function formatEstSeconds(seconds: number): string {
|
|||||||
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
||||||
imageQualityChoice.value = { file }
|
imageQualityChoice.value = { file }
|
||||||
imageQualityEstimates.value = new Map()
|
imageQualityEstimates.value = new Map()
|
||||||
imageSendTransport.value = 'lora'
|
|
||||||
imageTransportOptions.value = { fips: false, tor: false }
|
|
||||||
// Fire off estimates for all presets in parallel — each preset's nominal
|
// Fire off estimates for all presets in parallel — each preset's nominal
|
||||||
// target size (or the real file size for 'original') against the SAME
|
// target size (or the real file size for 'original') against the SAME
|
||||||
// mesh.transport-advice RPC the non-image attach flow already uses.
|
// mesh.transport-advice RPC the non-image attach flow already uses.
|
||||||
@ -1542,7 +1460,6 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
|
|||||||
const label =
|
const label =
|
||||||
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
||||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
||||||
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
|
|
||||||
} catch {
|
} catch {
|
||||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
||||||
}
|
}
|
||||||
@ -1671,18 +1588,12 @@ async function handleAttachFile(ev: Event) {
|
|||||||
attaching.value = true
|
attaching.value = true
|
||||||
attachError.value = null
|
attachError.value = null
|
||||||
try {
|
try {
|
||||||
let forcedTransport: 'fips' | 'tor' | null = null
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
const preset = await openImageQualityDialog(file, peer.contact_id)
|
const preset = await openImageQualityDialog(file, peer.contact_id)
|
||||||
if (!preset) return // user cancelled
|
if (!preset) return // user cancelled
|
||||||
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
|
|
||||||
file = await compressImage(file, preset)
|
file = await compressImage(file, preset)
|
||||||
}
|
}
|
||||||
if (forcedTransport) {
|
if (!(await sendFileViaBestTransport(file, peer))) return
|
||||||
// User picked a FIPS/Tor pill in the image modal — skip the LoRa
|
|
||||||
// advice/chooser flow entirely and ship via the content-ref path.
|
|
||||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
|
||||||
} else if (!(await sendFileViaBestTransport(file, peer))) return
|
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1872,14 +1783,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
|
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<a
|
||||||
|
href="https://flasher.meshcore.co.uk/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
class="glass-button mesh-flasher-btn"
|
class="glass-button mesh-flasher-btn"
|
||||||
:disabled="!flashLoraPath"
|
|
||||||
:title="flashLoraPath ? '' : 'Plug in a LoRa radio first'"
|
|
||||||
@click="openFlashLora"
|
|
||||||
>
|
>
|
||||||
Flash LoRa
|
Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error banner -->
|
<!-- Error banner -->
|
||||||
@ -1978,11 +1889,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
>
|
>
|
||||||
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
||||||
</button>
|
</button>
|
||||||
<!-- While refreshing show ONLY the spinner — spinner + "Refreshing…"
|
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
|
||||||
together overflow the button's fixed width. -->
|
|
||||||
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
|
|
||||||
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
|
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
|
||||||
<template v-if="!refreshing">Refresh</template>
|
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2173,7 +2082,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
|
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||||
No messages yet. Say hello!
|
No messages yet. Say hello!
|
||||||
</div>
|
</div>
|
||||||
@ -2343,8 +2252,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<!-- Default: plain text -->
|
<!-- Default: plain text -->
|
||||||
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
||||||
<div class="mesh-chat-bubble-meta">
|
<div class="mesh-chat-bubble-meta">
|
||||||
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
|
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
|
||||||
<button class="mesh-chat-more-btn" title="How this message traveled" aria-label="Show message route" @click.stop="hopVizMsg = msg">⋯</button>
|
|
||||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||||
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
|
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
|
||||||
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
||||||
@ -2375,25 +2283,17 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
|
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
|
||||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
|
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
|
||||||
<button
|
<button
|
||||||
class="mesh-chat-action-btn"
|
v-for="emoji in QUICK_REACTIONS"
|
||||||
:class="{ active: reactionPickerForId === msg.id }"
|
:key="emoji"
|
||||||
|
class="mesh-chat-reaction-btn"
|
||||||
|
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
||||||
:disabled="reactionInFlight !== null"
|
:disabled="reactionInFlight !== null"
|
||||||
@click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
|
@click="reactTo(msg, emoji)"
|
||||||
>😀 React ▾</button>
|
>
|
||||||
|
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
||||||
|
<span v-else>{{ emoji }}</span>
|
||||||
|
</button>
|
||||||
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
|
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
|
||||||
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
|
|
||||||
<button
|
|
||||||
v-for="emoji in REACTION_PALETTE"
|
|
||||||
:key="emoji"
|
|
||||||
class="mesh-chat-reaction-btn"
|
|
||||||
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
|
||||||
:disabled="reactionInFlight !== null"
|
|
||||||
@click="reactTo(msg, emoji)"
|
|
||||||
>
|
|
||||||
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
|
||||||
<span v-else>{{ emoji }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2532,11 +2432,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||||
(inline-chunked) and Tor. User picks which path to send it over.
|
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||||
Teleported to body so the fixed backdrop covers the FULL viewport —
|
|
||||||
rendered in place it sits inside a transformed/filtered glass panel,
|
|
||||||
which traps position:fixed to just the right chat panel. -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||||
<div class="glass-card mesh-transport-modal">
|
<div class="glass-card mesh-transport-modal">
|
||||||
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
||||||
@ -2565,13 +2461,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Image quality-picker modal: shown before sending an image attachment.
|
<!-- Image quality-picker modal: shown before sending an image attachment.
|
||||||
Each preset shows its nominal size target + a transfer-time estimate
|
Each preset shows its nominal size target + a transfer-time estimate
|
||||||
from the same mesh.transport-advice RPC the file-attach flow uses.
|
from the same mesh.transport-advice RPC the file-attach flow uses. -->
|
||||||
Teleported to body — see the transport chooser above. -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
|
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
|
||||||
<div class="glass-card mesh-transport-modal">
|
<div class="glass-card mesh-transport-modal">
|
||||||
<h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
|
<h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
|
||||||
@ -2588,94 +2481,12 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
>
|
>
|
||||||
<span class="mesh-transport-icon">🖼️</span>
|
<span class="mesh-transport-icon">🖼️</span>
|
||||||
<span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
|
<span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
|
||||||
<span class="mesh-transport-meta">{{
|
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
|
||||||
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Transport pills: LoRa is always available (it's a mesh chat);
|
|
||||||
FIPS/Tor appear when the peer is a reachable federation node,
|
|
||||||
letting the user send the image that way instead. -->
|
|
||||||
<div
|
|
||||||
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
|
|
||||||
class="mesh-image-transport-row"
|
|
||||||
>
|
|
||||||
<span class="mesh-image-transport-caption">Send via</span>
|
|
||||||
<button
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'lora' }"
|
|
||||||
@click="imageSendTransport = 'lora'"
|
|
||||||
>📡 LoRa</button>
|
|
||||||
<button
|
|
||||||
v-if="imageTransportOptions.fips"
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'fips' }"
|
|
||||||
@click="imageSendTransport = 'fips'"
|
|
||||||
>⚡ FIPS</button>
|
|
||||||
<button
|
|
||||||
v-if="imageTransportOptions.tor"
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'tor' }"
|
|
||||||
@click="imageSendTransport = 'tor'"
|
|
||||||
>🧅 Tor</button>
|
|
||||||
</div>
|
|
||||||
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Hop visualization modal: click a message's transport pill to see how
|
|
||||||
it traveled — radio hops + live signal for LoRa transports, overlay/
|
|
||||||
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
|
|
||||||
<div class="glass-card mesh-transport-modal">
|
|
||||||
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) || 'Message' }} route</h3>
|
|
||||||
<p class="mesh-transport-sub">
|
|
||||||
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
|
|
||||||
</p>
|
|
||||||
<div class="mesh-hopviz-chain">
|
|
||||||
<div class="mesh-hopviz-node">
|
|
||||||
<span class="mesh-hopviz-icon">🏝️</span>
|
|
||||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="hopVizMsg.transport === 'tor'">
|
|
||||||
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="hopVizMsg.transport === 'fips'">
|
|
||||||
<div class="mesh-hopviz-link">⚡ FIPS overlay · direct peer-to-peer</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="!hopVizMsg.transport">
|
|
||||||
<div class="mesh-hopviz-link">🛰 transport wasn't recorded for this message</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="mesh-hopviz-link">
|
|
||||||
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
|
|
||||||
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
|
|
||||||
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node">•</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="mesh-hopviz-node">
|
|
||||||
<span class="mesh-hopviz-icon">🏝️</span>
|
|
||||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
|
|
||||||
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
|
|
||||||
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
|
|
||||||
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
|
|
||||||
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
|
||||||
</div>
|
|
||||||
<div class="mesh-hopviz-signal">
|
|
||||||
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
|
|
||||||
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
|
|
||||||
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- The "mesh device detected" setup flow is now the global
|
<!-- The "mesh device detected" setup flow is now the global
|
||||||
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
||||||
|
|||||||
@ -978,11 +978,6 @@ async function downloadUpdate() {
|
|||||||
updateInProgress.value = false
|
updateInProgress.value = false
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
showStatus(t('systemUpdate.upToDateMessage'))
|
showStatus(t('systemUpdate.upToDateMessage'))
|
||||||
} else if (/already running/i.test(msg)) {
|
|
||||||
// A download/apply is already in flight (auto-updater or another tab) —
|
|
||||||
// that's progress, not failure. Re-sync state and say so calmly.
|
|
||||||
await loadStatus()
|
|
||||||
showStatus(t('systemUpdate.downloadInProgress'))
|
|
||||||
} else {
|
} else {
|
||||||
// A failed download is NOT a staged update — return the UI to the
|
// A failed download is NOT a staged update — return the UI to the
|
||||||
// Download button so the user can retry, instead of stranding them on
|
// Download button so the user can retry, instead of stranding them on
|
||||||
@ -1070,20 +1065,6 @@ async function applyUpdate() {
|
|||||||
setTimeout(() => window.location.reload(), 3000)
|
setTimeout(() => window.location.reload(), 3000)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// "Another update operation is already running" is NOT a failure — an
|
|
||||||
// apply (or the auto-updater) is already doing the work. Presenting it
|
|
||||||
// as an error made a successfully-updating node look broken (OptiPlex,
|
|
||||||
// v1.7.118 rollout). Join the in-flight apply instead: same overlay,
|
|
||||||
// same wait-for-new-version polling.
|
|
||||||
if (/already running/i.test(errorMessage(e))) {
|
|
||||||
applying.value = false
|
|
||||||
if (target) {
|
|
||||||
startInstallOverlay(target)
|
|
||||||
} else {
|
|
||||||
showStatus(t('systemUpdate.applyInProgress'))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showStatus(t('systemUpdate.applyFailed'), true)
|
showStatus(t('systemUpdate.applyFailed'), true)
|
||||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||||
applying.value = false
|
applying.value = false
|
||||||
|
|||||||
@ -98,36 +98,6 @@ describe('appsConfig service filtering', () => {
|
|||||||
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
|
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never offers Launch for an unknown container with a bare exposed port', () => {
|
|
||||||
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
|
|
||||||
// has a runtime lan-address — but no manifest-declared or probed UI. It
|
|
||||||
// must classify as a service and must NOT get a Launch button.
|
|
||||||
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
|
|
||||||
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
|
||||||
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
|
|
||||||
expect(canLaunch(selfDeployed)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers Launch for an unknown container once the backend confirms a UI', () => {
|
|
||||||
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
|
|
||||||
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
|
||||||
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
|
||||||
expect(canLaunch(confirmedUi)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('never offers Launch for curated service containers even with a UI flag', () => {
|
|
||||||
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
|
|
||||||
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
|
||||||
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
|
||||||
expect(canLaunch(service)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
|
|
||||||
const known = makePkg('jellyfin', 'Jellyfin', 'media')
|
|
||||||
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
|
||||||
expect(canLaunch(known)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||||
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||||
pkg.state = PackageState.Starting
|
pkg.state = PackageState.Starting
|
||||||
|
|||||||
@ -255,17 +255,9 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
|||||||
|
|
||||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||||
// Headless backends never get a Launch button, even with a published port.
|
|
||||||
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
|
||||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||||
// A bare runtime address is only a launch signal for curated apps: the
|
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||||
// backend now sets interfaces.main.ui strictly for confirmed web UIs
|
|
||||||
// (manifest declaration or HTTP probe), so an unknown container with an
|
|
||||||
// exposed non-UI port must not become launchable just for having one.
|
|
||||||
const hasUI = pkg.manifest.interfaces?.main?.ui
|
|
||||||
|| hasKnownLaunchUrl
|
|
||||||
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
|
|
||||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
|
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
|
||||||
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
|
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
|
||||||
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
||||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
||||||
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
||||||
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||||
@ -582,64 +581,3 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
|||||||
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||||
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
||||||
.mesh-transport-cancel:hover { color: #fff; }
|
.mesh-transport-cancel:hover { color: #fff; }
|
||||||
|
|
||||||
/* Transport pills at the bottom of the image quality modal — pick LoRa vs
|
|
||||||
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
|
|
||||||
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
||||||
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
|
||||||
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
|
||||||
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
|
|
||||||
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
|
|
||||||
|
|
||||||
/* Hop visualization modal + clickable transport pill */
|
|
||||||
.mesh-chat-transport-clickable { cursor: pointer; }
|
|
||||||
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
|
|
||||||
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
|
|
||||||
/* Animated route reveal: endpoints and link fade/slide in sequence, then a
|
|
||||||
pulse travels the link continuously to show the direction of travel. */
|
|
||||||
.mesh-hopviz-chain > * { opacity: 0; animation: mesh-hopviz-appear 0.4s ease forwards; }
|
|
||||||
.mesh-hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
|
|
||||||
.mesh-hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
|
|
||||||
.mesh-hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
|
|
||||||
@keyframes mesh-hopviz-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
||||||
.mesh-hopviz-link { position: relative; overflow: hidden; }
|
|
||||||
.mesh-hopviz-link::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
left: -18%;
|
|
||||||
width: 18%;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(251,146,60,0.95), transparent);
|
|
||||||
animation: mesh-hopviz-travel 1.6s linear infinite;
|
|
||||||
animation-delay: 1s;
|
|
||||||
}
|
|
||||||
@keyframes mesh-hopviz-travel { from { left: -18%; } to { left: 100%; } }
|
|
||||||
.mesh-hopviz-dot { animation: mesh-hopviz-blink 1.6s ease-in-out infinite; }
|
|
||||||
.mesh-hopviz-dot:nth-child(2) { animation-delay: 0.25s; }
|
|
||||||
.mesh-hopviz-dot:nth-child(3) { animation-delay: 0.5s; }
|
|
||||||
.mesh-hopviz-dot:nth-child(4) { animation-delay: 0.75s; }
|
|
||||||
.mesh-hopviz-dot:nth-child(5) { animation-delay: 1s; }
|
|
||||||
.mesh-hopviz-dot:nth-child(6) { animation-delay: 1.25s; }
|
|
||||||
@keyframes mesh-hopviz-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.mesh-hopviz-chain > * { animation: none; opacity: 1; }
|
|
||||||
.mesh-hopviz-link::before { animation: none; display: none; }
|
|
||||||
.mesh-hopviz-dot { animation: none; }
|
|
||||||
}
|
|
||||||
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
|
|
||||||
.mesh-hopviz-icon { font-size: 1.8rem; }
|
|
||||||
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
|
|
||||||
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
|
|
||||||
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
|
|
||||||
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
|
||||||
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
|
|
||||||
|
|
||||||
/* Per-message "more" button — opens the route/hops modal (same target as the
|
|
||||||
transport pill, but always visible and discoverable). */
|
|
||||||
.mesh-chat-more-btn { background: none; border: none; color: rgba(255,255,255,0.45); font-size: 0.9rem; line-height: 1; padding: 0 4px; cursor: pointer; border-radius: 6px; }
|
|
||||||
.mesh-chat-more-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
|
||||||
|
|
||||||
/* Reaction dropdown inside the message action menu */
|
|
||||||
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
|
|
||||||
|
|||||||
@ -362,41 +362,18 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
<!-- v1.7.118-alpha -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.118-alpha</span>
|
|
||||||
<span class="text-xs text-white/40">July 29, 2026</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- v1.7.117-alpha -->
|
<!-- v1.7.117-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.117-alpha</span>
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.117-alpha</span>
|
||||||
<span class="text-xs text-white/40">July 29, 2026</span>
|
<span class="text-xs text-white/40">July 27, 2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p>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.</p>
|
<p>Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
|
<p>Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.7.116-alpha -->
|
<!-- v1.7.116-alpha -->
|
||||||
|
|||||||
@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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."
|
||||||
"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.)"
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.7.118-alpha",
|
"current_version": "1.7.116-alpha",
|
||||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
|
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.7.118-alpha",
|
"new_version": "1.7.116-alpha",
|
||||||
"sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
|
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
||||||
"size_bytes": 53336720
|
"size_bytes": 51787208
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.7.118-alpha",
|
"current_version": "1.7.116-alpha",
|
||||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago-frontend-1.7.118-alpha.tar.gz",
|
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.7.118-alpha.tar.gz",
|
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||||
"new_version": "1.7.118-alpha",
|
"new_version": "1.7.116-alpha",
|
||||||
"sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
|
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
||||||
"size_bytes": 210502532
|
"size_bytes": 178062490
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-07-29",
|
"release_date": "2026-07-27",
|
||||||
"signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
|
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
||||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||||
"version": "1.7.118-alpha"
|
"version": "1.7.116-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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."
|
||||||
"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.)"
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.7.118-alpha",
|
"current_version": "1.7.116-alpha",
|
||||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
|
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.7.118-alpha",
|
"new_version": "1.7.116-alpha",
|
||||||
"sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
|
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
||||||
"size_bytes": 53336720
|
"size_bytes": 51787208
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.7.118-alpha",
|
"current_version": "1.7.116-alpha",
|
||||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago-frontend-1.7.118-alpha.tar.gz",
|
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.7.118-alpha.tar.gz",
|
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||||
"new_version": "1.7.118-alpha",
|
"new_version": "1.7.116-alpha",
|
||||||
"sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
|
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
||||||
"size_bytes": 210502532
|
"size_bytes": 178062490
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-07-29",
|
"release_date": "2026-07-27",
|
||||||
"signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
|
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
||||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||||
"version": "1.7.118-alpha"
|
"version": "1.7.116-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,7 +82,6 @@ def _write_rns_config(
|
|||||||
no_radio: bool,
|
no_radio: bool,
|
||||||
tcp_listen: str | None = None,
|
tcp_listen: str | None = None,
|
||||||
tcp_connect: list[str] | None = None,
|
tcp_connect: list[str] | None = None,
|
||||||
enable_transport: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP
|
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP
|
||||||
server/client interface(s) for radio-less dev/verification (e.g. Aurora
|
server/client interface(s) for radio-less dev/verification (e.g. Aurora
|
||||||
@ -137,7 +136,7 @@ def _write_rns_config(
|
|||||||
)
|
)
|
||||||
cfg.write_text(
|
cfg.write_text(
|
||||||
"[reticulum]\n"
|
"[reticulum]\n"
|
||||||
f" enable_transport = {'yes' if enable_transport else 'no'}\n"
|
" enable_transport = no\n"
|
||||||
" share_instance = no\n"
|
" share_instance = no\n"
|
||||||
" panic_on_interface_error = no\n\n"
|
" panic_on_interface_error = no\n\n"
|
||||||
"[interfaces]\n" + interfaces
|
"[interfaces]\n" + interfaces
|
||||||
@ -192,7 +191,6 @@ class ReticulumDaemon:
|
|||||||
no_radio=self.args.no_radio,
|
no_radio=self.args.no_radio,
|
||||||
tcp_listen=self.args.tcp_listen,
|
tcp_listen=self.args.tcp_listen,
|
||||||
tcp_connect=self.args.tcp_connect,
|
tcp_connect=self.args.tcp_connect,
|
||||||
enable_transport=self.args.enable_transport,
|
|
||||||
)
|
)
|
||||||
self.reticulum = RNS.Reticulum(configdir=str(configdir))
|
self.reticulum = RNS.Reticulum(configdir=str(configdir))
|
||||||
self.identity = load_identity(self.seed)
|
self.identity = load_identity(self.seed)
|
||||||
@ -439,29 +437,13 @@ class ReticulumDaemon:
|
|||||||
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
||||||
"archy", "resource",
|
"archy", "resource",
|
||||||
)
|
)
|
||||||
# established_callback identifies us to the peer BEFORE any Resource
|
link = RNS.Link(out_dest)
|
||||||
# goes out: the receiving daemon attributes an inbound resource via
|
|
||||||
# link.get_remote_identity() (see _on_resource_received), which is
|
|
||||||
# None unless the initiator calls identify() — without this every
|
|
||||||
# transfer arrives with an empty source_hash and the Rust side can't
|
|
||||||
# route it to a contact (observed live 2026-07-28: 5KB image sent,
|
|
||||||
# concluded COMPLETE, never surfaced on the receiving node).
|
|
||||||
link = RNS.Link(
|
|
||||||
out_dest,
|
|
||||||
established_callback=lambda lk: self._on_out_link_established(
|
|
||||||
resource_hash, lk
|
|
||||||
),
|
|
||||||
)
|
|
||||||
link.set_link_closed_callback(
|
link.set_link_closed_callback(
|
||||||
lambda lk: self.links.pop(resource_hash, None)
|
lambda lk: self.links.pop(resource_hash, None)
|
||||||
)
|
)
|
||||||
self.links[resource_hash] = link
|
self.links[resource_hash] = link
|
||||||
return link, resource_hash
|
return link, resource_hash
|
||||||
|
|
||||||
def _on_out_link_established(self, resource_hash: bytes, link):
|
|
||||||
link.identify(self.identity)
|
|
||||||
self._flush_pending_resource_sends(resource_hash, link)
|
|
||||||
|
|
||||||
def _send_resource(self, req: dict):
|
def _send_resource(self, req: dict):
|
||||||
import RNS
|
import RNS
|
||||||
req_id = req.get("id", "")
|
req_id = req.get("id", "")
|
||||||
@ -481,14 +463,14 @@ class ReticulumDaemon:
|
|||||||
if link.status == RNS.Link.ACTIVE:
|
if link.status == RNS.Link.ACTIVE:
|
||||||
self._start_resource(link, data, req_id)
|
self._start_resource(link, data, req_id)
|
||||||
else:
|
else:
|
||||||
# Link is establishing — queue; the creation-time established
|
# Link is establishing — queue and flush from the established
|
||||||
# callback (_on_out_link_established) identifies us then flushes
|
# callback. set_link_established_callback only fires once per Link
|
||||||
# the queue. Re-check afterwards to close the race where the
|
# object (the cache is reused across sends), so re-set it here to
|
||||||
# link went ACTIVE between the status check and the append
|
# make sure THIS send's queue entry gets flushed too.
|
||||||
# (the callback fires once, on the RNS thread).
|
|
||||||
self.pending_resource_sends[resource_hash].append((data, req_id))
|
self.pending_resource_sends[resource_hash].append((data, req_id))
|
||||||
if link.status == RNS.Link.ACTIVE:
|
link.set_link_established_callback(
|
||||||
self._flush_pending_resource_sends(resource_hash, link)
|
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
|
||||||
|
)
|
||||||
|
|
||||||
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
||||||
import RNS
|
import RNS
|
||||||
@ -536,17 +518,10 @@ class ReticulumDaemon:
|
|||||||
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
||||||
if identity is not None else ""
|
if identity is not None else ""
|
||||||
)
|
)
|
||||||
# RNS hands a concluded Resource's data as a file-like object
|
|
||||||
# (BufferedReader over the assembled stream), not bytes —
|
|
||||||
# observed live 2026-07-28: b64encode raised "a bytes-like
|
|
||||||
# object is required" and every received transfer was lost.
|
|
||||||
data = resource.data
|
|
||||||
if hasattr(data, "read"):
|
|
||||||
data = data.read()
|
|
||||||
self._emit_threadsafe({
|
self._emit_threadsafe({
|
||||||
"event": "resource_recv",
|
"event": "resource_recv",
|
||||||
"source_hash": source_hash,
|
"source_hash": source_hash,
|
||||||
"data_b64": base64.b64encode(data).decode("ascii"),
|
"data_b64": base64.b64encode(resource.data).decode("ascii"),
|
||||||
})
|
})
|
||||||
except Exception as e: # never let a callback kill the RNS thread
|
except Exception as e: # never let a callback kill the RNS thread
|
||||||
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
||||||
@ -556,14 +531,7 @@ class ReticulumDaemon:
|
|||||||
sock_path = self.args.socket
|
sock_path = self.args.socket
|
||||||
if os.path.exists(sock_path):
|
if os.path.exists(sock_path):
|
||||||
os.unlink(sock_path)
|
os.unlink(sock_path)
|
||||||
# limit: asyncio's default per-line StreamReader cap is 64KiB, but
|
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
|
||||||
# send_resource requests carry the whole payload base64-encoded on one
|
|
||||||
# JSON line — a ~48KB+ attachment overflows the default and the raised
|
|
||||||
# LimitOverrunError tears down the RPC connection (the Rust side then
|
|
||||||
# sees "reticulum-daemon is gone" and restarts the whole session).
|
|
||||||
server = await asyncio.start_unix_server(
|
|
||||||
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
|
|
||||||
)
|
|
||||||
os.chmod(sock_path, 0o600)
|
os.chmod(sock_path, 0o600)
|
||||||
self.announce()
|
self.announce()
|
||||||
async with server:
|
async with server:
|
||||||
@ -643,10 +611,6 @@ def _parse_args(argv):
|
|||||||
p.add_argument("--txpower", type=int, default=17)
|
p.add_argument("--txpower", type=int, default=17)
|
||||||
p.add_argument("--spreadingfactor", type=int, default=8)
|
p.add_argument("--spreadingfactor", type=int, default=8)
|
||||||
p.add_argument("--codingrate", type=int, default=5)
|
p.add_argument("--codingrate", type=int, default=5)
|
||||||
p.add_argument("--enable-transport", action="store_true",
|
|
||||||
help="run as an RNS transport node: relay traffic and rebroadcast "
|
|
||||||
"announces so nodes beyond direct RF range discover each other "
|
|
||||||
"through this one")
|
|
||||||
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
|
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
|
||||||
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
|
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
|
||||||
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")
|
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")
|
||||||
|
|||||||
@ -115,23 +115,6 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
|||||||
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
|
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
|
||||||
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
|
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
|
||||||
fi
|
fi
|
||||||
# Packaged radio tools ride the runtime payload: OTA-only nodes never
|
|
||||||
# get them any other way — the v1.7.117 rollout left fleet nodes with
|
|
||||||
# a stale archy-reticulum-daemon (exits on the new --enable-transport
|
|
||||||
# flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails
|
|
||||||
# with "No such file or directory"). bootstrap.rs promotes these to
|
|
||||||
# /usr/local/bin on first startup after the update.
|
|
||||||
for tool in archy-reticulum-daemon archy-rnodeconf; do
|
|
||||||
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
|
|
||||||
mkdir -p "$RUNTIME_DIR/radio-tools"
|
|
||||||
echo " Including radio tool $tool"
|
|
||||||
cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool"
|
|
||||||
else
|
|
||||||
echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2
|
|
||||||
rm -rf "$STAGING_DIR"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
|
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
|
||||||
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||||
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
|
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
|
||||||
|
|||||||
@ -167,16 +167,6 @@ if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[4b/8] Building packaged radio tools (archy-reticulum-daemon, archy-rnodeconf)..."
|
|
||||||
# These ride the frontend tarball's runtime payload (radio-tools/) and are
|
|
||||||
# promoted to /usr/local/bin by bootstrap.rs — the ONLY path that updates them
|
|
||||||
# on OTA-only nodes. Stale-dist releases re-broke fleet mesh once (v1.7.117),
|
|
||||||
# so always rebuild here; the manifest script hard-fails if they're missing.
|
|
||||||
(cd "$PROJECT_ROOT/reticulum-daemon" && ./build.sh) || {
|
|
||||||
echo "Error: reticulum-daemon/build.sh failed — radio tools are release-critical" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[5/8] Validating curated changelog..."
|
echo "[5/8] Validating curated changelog..."
|
||||||
|
|
||||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user