Compare commits

...
Author SHA1 Message Date
archipelago 2428f34614 chore: publish release v1.9.1-alpha
Demo images / Build & push demo images (push) Failing after 1m22s
2026-10-10 17:27:04 -04:00
archipelago 7f0318c68a chore: prepare release v1.9.1-alpha 2026-10-10 16:43:31 -04:00
archipelago 733c4b1d90 Merge #54518609: docs: sync What's New modal for v1.9.1-alpha
Demo images / Build & push demo images (push) Failing after 37s
nostr:nevent1qqs9g5vxpyqx03ecj4uamnhf075qvph386zhg03tg660wftchvx0urcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc4334mt

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Generated by scripts/sync-whats-new.py from the curated changelog; release gate whats-new-sync now passes.
2026-10-10 14:56:32 -04:00
archipelago a242221641 docs: sync What's New modal for v1.9.1-alpha 2026-10-10 14:56:11 -04:00
archipelago 384fb7f881 Merge #130a8e0a: docs: curate v1.9.1-alpha changelog
nostr:nevent1qqspxz5wpg55t4zlxwhxanwrnehxuvuglq0jl60wlztulatlqqn57zspz3mhxue69uhhyetvv9ujumn8d96zuer9wcemv28w

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Release notes including explicit known limitations for open gates.
2026-10-10 13:08:49 -04:00
archipelago f00e2c1033 docs: curate v1.9.1-alpha changelog 2026-10-10 13:05:00 -04:00
archipelago c970ff3bc1 Merge #daf92535: test: remove stale Web5QuickActions connection-entry t…
Demo images / Build & push demo images (push) Failing after 35s
test: remove stale Web5QuickActions connection-entry test

nostr:nevent1qqsd47f9xh6c7klrzxz4xxtlcj4qmmkt4l63lzzcnzf7w8ngklakhdgpz3mhxue69uhhyetvv9ujumn8d96zuer9wctcqjym

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Nav moved to Web5ConnectedNodes (5f01efda6), already covered by Web5ConnectedNodes.test.ts; stale test failed release ui-unit-tests gate.
2026-10-10 13:04:25 -04:00
archipelago 019a2fa35c test: remove stale Web5QuickActions connection-entry test
The Node connections nav moved to Web5ConnectedNodes in 5f01efda6 and is
covered by Web5ConnectedNodes.test.ts; the old test asserted removed markup
and failed the release ui-unit-tests gate.
2026-10-10 13:04:15 -04:00
archipelago ec76512ae7 Merge #807b206b: fix(ui): show live free disk capacity with TB units
Demo images / Build & push demo images (push) Failing after 40s
nostr:nevent1qqsgq7eqdd4633p8glspy29wev487tjjmnccnc0qrhh5zk72y4dzapcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc59jes7

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

system.stats not deduplicated; Server disk status uses non-persisted server.disk-status-v2; Home and Monitoring show "X free / Y total" with TB formatting. 19 focused tests pass.
2026-10-10 12:56:40 -04:00
archipelago 0d16c53ccb Merge #d273c34c: fix: keep IndeeHub provider injection idempotent for b…
fix: keep IndeeHub provider injection idempotent for bundled frontends

nostr:nevent1qqsdyu7rfnaw90zy0thscqrxxw6x4pj606gfn4wf8akdqz7qx7v2ghqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcqetpdc

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Nginx patch omits/strips sub_filter provider when index.html bundles it (idempotent), cache-bust tab-signer-v5; rental request accepts validated boolean renewExpired. Tests: focused frontend 31 pass, isolated backend test pass, production build pass.
2026-10-10 12:56:35 -04:00
archipelago d281171124 fix(ui): show live free disk capacity with TB units
system.stats is no longer deduplicated, Server disk status uses a
non-persisted v2 cache key, and Home/Monitoring show 'X free / Y total'.
Adds focused regression tests.
2026-10-10 12:54:23 -04:00
archipelago dfc4c659a6 fix: keep IndeeHub provider injection idempotent for bundled frontends
When the IndeeHub container's own index.html already bundles
nostr-provider.js, the managed nginx patch no longer injects a sub_filter
provider and strips any existing one, staying idempotent either way; the
provider cache-bust marker moves to tab-signer-v5. The rental request API
now accepts an explicit boolean renewExpired choice, validates it, and
forwards it to the dashboard bridge, with a focused provider regression.
2026-10-10 10:52:45 -04:00
archipelago 1102566ce4 Merge #0c35154d: Record three-node rental renewal UAT
nostr:nevent1qqsqcdg4fhy0k70jprsj49pqpz085wgam0y72tp5953udnmwgz33ljspz3mhxue69uhhyetvv9ujumn8d96zuer9wc757ude

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Records the reviewed ngit merge, exact deployed artifact hashes, three-node health and container-preservation evidence, Framework duplicate-provider correction, and remaining OTA/ISO release gates.
2026-10-10 10:11:18 -04:00
archipelago ef8c2c30dc docs: record three-node rental renewal UAT 2026-10-10 10:11:01 -04:00
archipelago f6e84fdcb4 Merge #267f6369: Allow explicit renewal after rental expiry
Demo images / Build & push demo images (push) Failing after 1m18s
nostr:nevent1qqszvlmrdyh3dte5wudhy68l6u2s97dpkzrkvyzywv9m3y283jpzztgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcqxz97r

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Require an explicit user renewal after the authenticated seller confirms the original timed rental expired; preserve active and incomplete purchase recovery without another payment. Includes the mobile Map/Refresh layout correction and release-from-main operator runbook. Isolated backend regression, 29 dashboard bridge tests, 10 IndeeHub player tests, focused connected-node tests, and production dashboard build passed.
2026-10-10 09:57:28 -04:00
archipelago a4df61000c fix: allow explicit renewal of expired rentals 2026-10-10 09:55:43 -04:00
archipelago 2dc6121eea Merge #feefe532: Record three-node UAT merge in release ledger
Demo images / Build & push demo images (push) Failing after 2m14s
nostr:nevent1qqs0aml9xgqxqzgrwku8sh6p6uvjrrwaxralf2agsvhxc3vld8ty4fcpz3mhxue69uhhyetvv9ujumn8d96zuer9wcsnjqy3

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Records the reviewed ngit proposal and its resulting merge commit, as required by the release checklist.
2026-10-10 07:57:09 -04:00
archipelago 658a8922d8 docs: record three-node UAT merge 2026-10-10 07:56:23 -04:00
archipelago 23c88da4c4 Merge #e970261b: Fix IndeeHub FIPS playback, terminal, and mesh chat UA…
Fix IndeeHub FIPS playback, terminal, and mesh chat UAT regressions

nostr:nevent1qqswjupxr00327qffnq9rqy98wzslt7lp9gtk0e9p5m5vus5f4tkhlqpz3mhxue69uhhyetvv9ujumn8d96zuer9wc0u8v0w

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Three-node UAT fixes for FIPS catalog/playback bridging, guarded free-stream policy, developer terminal runtime/proxy/UI, MeshCore public chat parsing, and Connected Nodes mobile navigation. Includes retained acceptance evidence and release gates.
2026-10-10 07:56:05 -04:00
archipelago 71de1a3c27 fix terminal mesh chat and guarded IndeeHub playback 2026-10-10 07:52:47 -04:00
archipelago bbf8dc76c4 proxy free IndeeHub playback across nodes 2026-10-10 06:05:45 -04:00
archipelago 9d5e73455a route indeehub catalog through node FIPS 2026-10-10 04:27:41 -04:00
archipelago 5f01efda61 wip: preserve two-node IndeeHub UAT fixes 2026-10-09 21:17:08 -04:00
archipelago 54f1ec8322 Merge #748548a8: Record dual-node IndeeHub UAT and terminal acceptance…
Record dual-node IndeeHub UAT and terminal acceptance gaps

nostr:nevent1qqs8fp2g4r832dg0fq30v2280wx6fre70avqx7jkh9nu3d9pqn03vcqpz3mhxue69uhhyetvv9ujumn8d96zuer9wce3lkfj

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Tracks source-built API and worker parity, local film visibility, encrypted playback access checks, and remaining cross-node and authenticated UAT gates.
2026-10-09 18:18:00 -04:00
archipelago fc54525092 docs: record final dual-node IndeeHub UAT and remaining gates 2026-10-09 18:17:21 -04:00
archipelago e52c140069 Merge #d04a93c9: Record dual-node terminal and IndeeHub UAT gaps
nostr:nevent1qqsdqj5ne9culgafs6s4n3j2p5hq673fy39vself304uqj6zussjsegpz3mhxue69uhhyetvv9ujumn8d96zuer9wc90c8kj

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record the merged terminal proxy correction, exact dual-node build findings, and the still-open IndeeHub backend and publication acceptance gates.
2026-10-09 17:30:07 -04:00
archipelago 1f24000226 docs: retain terminal and IndeeHub dual-node UAT findings 2026-10-09 17:29:52 -04:00
archipelago b50fc5627c Merge #dd22151e: Fix developer terminal API routing on both dashboard l…
Fix developer terminal API routing on both dashboard listeners

nostr:nevent1qqsd6gs4r6x9tvd58qmxkp6kl0wap2tmc3fv28r6uxj29ej4fu3djygpz3mhxue69uhhyetvv9ujumn8d96zuer9wcasasgn

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Route /api/terminal/ to the authenticated backend before SPA fallback. Live Yaya UAT returned HTML 200 before the fix and JSON 401 after; Framework already had a manual route.
2026-10-09 17:26:20 -04:00
archipelago a42c24abdf fix: route developer terminal API before dashboard fallback 2026-10-09 17:25:41 -04:00
archipelago 5b0b35401b Merge #a7abeb2c: Clear recovered monitoring alerts
nostr:nevent1qqs202lt93tr40nh6ttlc06682a7qwkjwulntv822crzy785rtgxchspz3mhxue69uhhyetvv9ujumn8d96zuer9wcyxh3c7

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Reconcile notifications on empty metric-alert snapshots, remove resolved disk/RAM/CPU/latency notifications immediately, and preserve unrelated app/health notices. Isolated regression test running.
2026-10-09 16:09:52 -04:00
archipelago e1506a07b2 fix: clear recovered monitoring alerts promptly 2026-10-09 15:59:33 -04:00
archipelago a5d4781ef1 Merge #7dcf5456: Record dual-node IndeeHub UAT gates
nostr:nevent1qqs8mn6526xrlkdwfvcf3jq3rfyu2f8cquvf7m873yvya8leweehzxgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcxqeu9g

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Document tested Yaya and Framework UAT overrides, protected poster access, outstanding live acceptance, and stale disk-alert recovery.
2026-10-09 15:55:37 -04:00
archipelago 8d9f4c9ff0 docs: record dual-node IndeeHub UAT and disk alert gate 2026-10-09 15:54:54 -04:00
archipelago 7becb21376 Merge #08638143: Record UAT ngit merge dispositions
nostr:nevent1qqsqscupgwkuxcek7g62swtz2eevsp9k63jpwjc34ck74rrsmh55x5spz3mhxue69uhhyetvv9ujumn8d96zuer9wcfsa3h4

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record the reviewed hardware, terminal/security, author, and IndeeHub UAT checklist PR outcomes and main-only mirror verification in the release ledger.
2026-10-09 15:46:40 -04:00
archipelago 711db6b711 docs: record ngit UAT proposal dispositions 2026-10-09 15:46:31 -04:00
archipelago 4b65879b6c Merge #f9914a73: Track outstanding IndeeHub UAT acceptance
Demo images / Build & push demo images (push) Failing after 1m8s
nostr:nevent1qqs0ny22wd7hy0xu9yq95sl55cphpvwreak3dqwp599jjhnd86yay2qpz3mhxue69uhhyetvv9ujumn8d96zuer9wcnuv3tu

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Retain the operator-reported signer, upload, poster, Cloud picker, publishing, payment-mode, and durable-deployment failures as open acceptance gates.
2026-10-09 15:27:18 -04:00
archipelago 56cdfcafcc docs: retain IndeeHub UAT acceptance failures 2026-10-09 15:26:48 -04:00
archipelago 3667105b25 Merge #924f78e1: fix: report an installed app's declared author
nostr:nevent1qqsfynmcuy5fruzzxmvz7jdw47hf07nr8kjmuh2s0f8v8e9etr00n2qpz3mhxue69uhhyetvv9ujumn8d96zuer9wczsgytw

PR-Author: TheCryptoDonkey
nostr:npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2

PR description:

The scanner credits every installed app to Archipelago, whatever metadata.author its manifest declares. apply_manifest_value already reads icon, tier and licence from metadata; this reads author too, keeping the scanner's default when none is declared. One test added; docker_packages tests pass (27/27).
2026-10-09 15:21:57 -04:00
archipelago bb79612772 Merge #2e6577db: fix: restrict owner terminal to dashboard origin
nostr:nevent1qqszuethmwphvtkd9p8u4h22c9da3v49mjym8uk5g24tklfkdtytnqgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcrhpskm

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Review follow-up to terminal proposal c1156d20. Require the exact dashboard Origin and Host on list, create and WebSocket routes so same-host apps on other ports cannot access the owner shell; add cross-port and missing-Origin regressions.
2026-10-09 15:21:51 -04:00
archipelago 8a7cfffd46 fix: restrict owner terminal to dashboard origin 2026-10-09 15:10:11 -04:00
archipelago 02671438d8 Merge #c1156d20: Terminal developer environment, resumable TUI, and Arc…
Terminal developer environment, resumable TUI, and Archipelago skills

nostr:nevent1qqsvz9tdypdv9cmtjmsz6azv8yujk0xezwv0j4wpa57qnk60nfyj28gpz3mhxue69uhhyetvv9ujumn8d96zuer9wcffu24e

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds the authenticated resumable terminal session backend, xterm-based terminal UI with bounded scrolling and app-style controls, tmux key translation, Codex/node bootstrap, preloaded Archipelago skills, developer app starter, and UAT runbook.

Validation: npm run build:production; cargo check -p archipelago; scripts/tests/archy-developer-setup-test.sh. Framework UAT health and unauthenticated terminal endpoint checks pass.
2026-10-09 15:08:54 -04:00
archipelago a5637d9350 Merge #a9165a5e: 3D hardware models, NIP-99 discovery, and private phon…
Demo images / Build & push demo images (push) Failing after 41s
3D hardware models, NIP-99 discovery, and private phone goal

nostr:nevent1qqs2j9j6tc3kpsdp3jhwty3wt8yy3un7r93e4d4smjpza4nvt033ctgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq28amm

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds refined 3D hardware illustrations, OpenWrt and private-phone marketplace discovery, and the Setup goal for a non-surveillance phone. Production queries enabled Archipelago relays for NIP-99 kind-30402 listings; development mock cards are used only when DEV is enabled. Tested with production build plus Chromium, Firefox, and WebKit desktop/mobile browser checks. File-browser goal completion now opens the native Cloud files view.
2026-10-09 14:59:38 -04:00
yaya 19540c0578 Open file-browser goal in native Cloud view 2026-10-09 19:36:11 +01:00
yaya 52fb27fd43 fix(ui): use neutral loader before device models mount 2026-10-09 17:40:54 +01:00
archipelago 78e8b1d44a Merge #23778b8a: Record IndeeHub managed-service profile repair accepta…
Record IndeeHub managed-service profile repair acceptance

nostr:nevent1qqszxaut3ggsrgmxy6syc0xm55z30regcyurrt5lsgjwth70qqkgffqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcxnt75m

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Direct Podman restart fought systemd supervision and discarded API corrections. Record the read-only per-node Quadlet UAT mount, preserved original image and identities, two managed recreations, real native login acceptance after health, rollback, and image/upgrade hold. The isolated registration regression now passes.
2026-10-09 12:36:58 -04:00
archipelago ab6601db06 Record managed-service cause and persistent IndeeHub profile UAT repair 2026-10-09 12:36:33 -04:00
yaya ecac80dfcb fix(ui): restrict private phone discovery to pixel keyword 2026-10-09 17:30:04 +01:00
archipelago 0fb8ee7e5e Merge #0fb6debb: Record verified IndeeHub native login and registration…
Record verified IndeeHub native login and registration UAT repair

nostr:nevent1qqsqldk7hw5rhldq75z0jgx59wdr0e36qnglz7x28a2pgrfgr8sazmspz3mhxue69uhhyetvv9ujumn8d96zuer9wcq6p86p

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record accepted ngit merge 83feb180 and exact scope of successful real Home Serve authentication and registration readiness. Retain pending isolated Rust test, durable image packaging and full upload/publication acceptance.
2026-10-09 11:35:30 -04:00
archipelago 4ca0772db3 Record ngit merge and live IndeeHub authentication acceptance 2026-10-09 11:35:11 -04:00
archipelago 83feb18063 Merge #9d6de783: Fix IndeeHub registration for managed stack installati…
Fix IndeeHub registration for managed stack installations

nostr:nevent1qqsf6m08s06sy2emsk0qk8e73q0r38dyfqkathg4lu56gz6wyhd0ewqpz3mhxue69uhhyetvv9ujumn8d96zuer9wc4mpaz6

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Managed IndeeHub has one installed package record; its API runs as an internal stack component. Require the installed running parent and existing API identity pin, while retaining validation of legacy standalone API records. Includes regression coverage for parent-only state and records reopened identity/upload UAT. Focused isolated backend tests and production build are in progress.
2026-10-09 11:34:19 -04:00
archipelago 1d6ac1a6c5 Record verified Yaya native login and registration readiness repairs 2026-10-09 11:28:10 -04:00
archipelago 96b6ff9972 Record native autosign regression correction and live UAT limits 2026-10-09 11:09:45 -04:00
archipelago 424070d215 Fix native media registration for managed IndeeHub stacks 2026-10-09 10:45:38 -04:00
yaya dd909040f8 feat(ui): refine black Pixel model with official GrapheneOS boot artwork 2026-10-09 15:02:25 +01:00
archipelago 5ba52f1d21 Merge #479a4caf: docs: record Yaya IndeeHub UAT acceptance
nostr:nevent1qqsy0xjv4adzmz09rsfm06qlujc75fjaw35avdmshlsq4mqrja97gmgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcchzfuk

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record the resolved enabled-UAT maintenance guard, exact reviewed/mirrored commits and artifact hashes, the single committed supervised Yaya operation, preservation proofs, and disposable Chromium stale-session acceptance. Retain the non-authorizing secondary-cache limitation and the IndeeHub repository mirror blocker explicitly.
2026-10-09 09:51:09 -04:00
archipelago 8d5410566f docs: record Yaya IndeeHub UAT acceptance 2026-10-09 09:50:40 -04:00
archipelago 47f8aff3f9 Merge #a962fe38: fix: allow async-trait must-use compatibility lint
Demo images / Build & push demo images (push) Failing after 47s
nostr:nevent1qqs2jch78pdxt098826ghl6vx3pqxy9rsrn3u6rqawp9mptle7fv3sspz3mhxue69uhhyetvv9ujumn8d96zuer9wcrme3nq

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
2026-10-09 09:31:40 -04:00
yaya cfde1343a4 feat(ui): add private phone setup goal and shared hardware listings 2026-10-09 14:31:24 +01:00
archipelago daa4b55b4b test: allow quiet idempotent companion reconciliation 2026-10-09 09:20:18 -04:00
archipelago 846b316d72 fix: keep isolated backend tests hermetic and disk-backed 2026-10-09 09:09:16 -04:00
archipelago e2d926f5e0 fix: use explicit OS entropy for assistant and wallet IDs 2026-10-09 08:56:29 -04:00
yaya 03ee3c0162 fix(ui): keep marketplace cards within showcase height 2026-10-09 13:44:24 +01:00
archipelago 088eee55b8 fix: update atomic monitoring API for Rust 1.99 2026-10-09 08:43:56 -04:00
archipelago 48c13adaca fix: allow async-trait must-use compatibility lint 2026-10-09 08:43:56 -04:00
archipelago c6f8308e9a ci: add isolated ARM proposal test lane 2026-10-09 08:43:56 -04:00
archipelago 76141ffd2f fix: restore green source validation baseline 2026-10-09 08:43:56 -04:00
yaya e3d2f0b827 feat(ui): improve router product card layout 2026-10-09 13:43:31 +01:00
yaya c5cec95108 fix(ui): reserve visible viewport gutter below router 2026-10-09 13:42:57 +01:00
yaya 5ca345162d fix(ui): render dev router cards immediately 2026-10-09 13:42:28 +01:00
yaya 963dd03f83 fix(ui): add explicit bottom spacer after router container 2026-10-09 13:42:10 +01:00
yaya cc35b06ce6 fix(ui): show dev router card mocks without relays 2026-10-09 13:41:52 +01:00
yaya 3ca567c04e fix(ui): reserve bottom space below router container 2026-10-09 13:41:27 +01:00
yaya 7fde60126a fix(ui): align router card with page margins 2026-10-09 13:41:00 +01:00
yaya f7377b0ac8 feat(ui): add silent router lookup and dev listing mocks 2026-10-09 13:40:49 +01:00
yaya 9d1970a37d fix(ui): add page spacing around router showcase 2026-10-09 13:40:07 +01:00
yaya 2c7ba923cf fix(ui): query configured Archipelago Nostr relays 2026-10-09 13:39:08 +01:00
yaya f4cb5e2c74 fix(ui): preserve showcase height and broaden NIP-99 lookup 2026-10-09 13:38:15 +01:00
yaya 7c23a331ee fix(ui): remove overflowing fixed router height 2026-10-09 13:37:41 +01:00
yaya ced5342111 fix(ui): preserve router container height for marketplace 2026-10-09 13:37:22 +01:00
yaya d4d6a655e7 feat(ui): expand real NIP-99 router listings view 2026-10-09 13:37:00 +01:00
yaya 70cd80c3e8 feat(ui): add responsive router listing carousel 2026-10-09 13:35:51 +01:00
yaya 6dcaa41040 fix(ui): reserve bottom margin below router card 2026-10-09 13:34:50 +01:00
yaya 057ac1c68b fix(ui): cap router showcase to viewport height 2026-10-09 13:34:23 +01:00
yaya 1291ea1508 fix(ui): enlarge router model and hide router controls 2026-10-09 13:34:01 +01:00
yaya 6b97505a59 fix(ui): keep model browser for LoRa dev previews only 2026-10-09 13:33:46 +01:00
yaya 6d19cf27c3 fix(ui): make mobile router purchase action full width 2026-10-09 13:33:09 +01:00
yaya ca47c9fcb9 fix(ui): pin router purchase panel to showcase bottom 2026-10-09 13:32:45 +01:00
yaya df15a9c890 fix(ui): fill router showcase height on desktop 2026-10-09 13:32:20 +01:00
yaya 9936bda9ae fix(ui): nest router purchase panel beside model 2026-10-09 13:31:51 +01:00
yaya f9bfceb80e feat(ui): add router marketplace glass panel 2026-10-09 13:31:10 +01:00
yaya a6725c1b69 feat(ui): add dev model browser and soften hardware lighting 2026-10-09 13:26:41 +01:00
yaya 5d64991203 feat(ui): add interactive 3D LoRa devices and OpenWrt router 2026-10-09 13:15:59 +01:00
TheCryptoDonkey cb6be9edb5 fix: report an installed app's declared author
The scanner gives every installed app author "Archipelago", and the
app details sidebar shows it, so an app whose manifest declares
metadata.author (Wildbloom: ForgeSworn) is credited to the node OS.
apply_manifest_value already reads icon, tier and licence from the
manifest's metadata; it now reads author too. Without one the scanner's
default stays.
2026-10-09 12:49:05 +01:00
archipelago 5812f53c7c Merge #ecac4574: fix(indeehub): allow verified enabled UAT shutdown
nostr:nevent1qqswetz9wn77lnd30wgnkusdraa5gmdkekdx7crr779z944h4aknyrqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcw2g8t3

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Accept a money-free IndeeHub UAT instance when registration and publication are both exactly enabled or both exactly disabled. Continue rejecting missing, ambiguous, or mixed modes, and retain all zero-work, binary-hash, provider, and connection safeguards.\n\nValidation: 63 focused maintenance-controller tests passed. Isolated backend suite: 2,066 passed, 5 opt-in ignored, 0 failed.
2026-10-09 07:07:08 -04:00
archipelago 5e6260864e fix(indeehub): allow verified enabled UAT shutdown 2026-10-09 06:56:17 -04:00
155 changed files with 5128 additions and 6028 deletions
@@ -443,15 +443,23 @@ private fun injectTopInset(view: WebView) {
return '';
}
function apply() {
// IndeeHub's browse hero is an image, not a body background.
// Padding the body puts a solid strip above the image; let the
// hero start at the display edge while push() keeps its fixed
// header below the status icons. Other routes keep the normal
// content inset.
var imageHero = location.port === '7778' &&
(location.pathname === '/' || location.pathname === '/browse') &&
!!document.querySelector('section img');
var bodyBg = document.body
? getComputedStyle(document.body).backgroundColor : '';
var eff = effectiveTopBg();
// Repaint body only when its declared background is not what
// the page actually renders at the top (or paints nothing).
var fix = eff && eff !== bodyBg
var fix = !imageHero && eff && eff !== bodyBg
? 'background-color:' + eff + ' !important;' : '';
styleEl().textContent =
'body{padding-top:' + SAT + 'px !important;' +
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
'box-sizing:border-box !important;' + fix + '}';
}
function push(el) {
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## v1.9.1-alpha (2026-10-10)
- Add an owner terminal with xterm rendering, preserved scrollback, focused keyboard handling and Codex skills preinstalled; restrict it to the dashboard origin.
- Prevent monitoring alerts from lingering after the condition recovers.
- Show free disk capacity ("X free / Y total", TB-aware) from live data on Home, Monitoring and Server instead of a stale cached snapshot.
- Allow explicit renewal of expired IndeeHub rentals, and keep the IndeeHub provider injection idempotent when the app already bundles it.
- Make IndeeHub native login, registration and media registration reliable on managed stacks; route the IndeeHub catalog and free playback across nodes through FIPS.
- Add a private phone setup goal, shared hardware listings, GrapheneOS Pixel artwork and Archipelago router NIP-99 listings.
- Open the file-browser goal in the native Cloud view.
- Keep isolated backend tests hermetic and add an ARM proposal test lane.
Known limitations (open acceptance gates at publication):
- Lightning rental on IndeeHub is not accepted: BTCPay is unconfigured on the IndeeHub API.
- Metered ecash playback and the Shorty signed-in operator acceptance are not yet verified.
- Remaining items in the post-1.8.22 regression checklist stay open.
- Pre-existing historical mirror drift between ngit and Gitea is inventoried, not resolved.
## v1.9.0-alpha (2026-10-05)
Unpublished release candidate; qualification is still in progress.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.9.0-alpha"
version = "1.9.1-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.9.0-alpha"
version = "1.9.1-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+1 -1
View File
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
use tracing::{debug, info};
const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display.
+11 -9
View File
@@ -583,15 +583,17 @@ impl ApiHandler {
paid = true;
}
Ok(false) => {}
Err(_) => return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"paid": false,
"status": "unknown",
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
}))?),
)),
Err(_) => {
return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"paid": false,
"status": "unknown",
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
}))?),
))
}
}
}
let body = serde_json::json!({ "paid": paid });
@@ -193,16 +193,6 @@ impl ApiHandler {
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
let retained = source.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
})
+570 -4
View File
@@ -24,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use anyhow::{Context, Result};
use hyper::{Method, Request, Response, StatusCode};
use sha2::{Digest, Sha256};
use std::sync::Arc;
@@ -46,6 +46,43 @@ pub(super) fn build_response(
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
}
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
match value {
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
_ => false,
}
}
/// Public playback must be explicitly free in both the mutable project row and
/// any signed rental offer. A stale project price must never override signed
/// paid terms, and malformed offers fail closed.
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
if !zero_sats(listing.get("rentalPrice")) {
return false;
}
let Some(offer) = listing.get("offerEvent") else {
return true;
};
if offer.is_null() {
return true;
}
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
return false;
};
let Some(terms) = tags.iter().find_map(|tag| {
let tag = tag.as_array()?;
(tag.first()?.as_str()? == "rental")
.then(|| tag.get(1)?.as_str())
.flatten()
}) else {
return false;
};
serde_json::from_str::<serde_json::Value>(terms)
.ok()
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
}
pub struct ApiHandler {
config: Config,
rpc_handler: Arc<RpcHandler>,
@@ -225,6 +262,339 @@ impl ApiHandler {
))
}
/// Serve IndeeHub's sanitized public project catalog on the FIPS peer
/// listener. The app container remains on loopback; peers never receive
/// its session-gated port directly.
async fn handle_indeehub_public_catalog(&self) -> Result<Response<hyper::Body>> {
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?
.get("http://127.0.0.1:7778/api/projects/public-catalog")
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed");
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
};
if !response.status().is_success() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
let bytes = response.bytes().await?;
anyhow::ensure!(
bytes.len() <= 8 * 1024 * 1024,
"IndeeHub catalog exceeds limit"
);
let value: serde_json::Value =
serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?;
anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array");
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(bytes),
))
}
/// Resolve a free IndeeHub title to its local HLS manifest. IndeeHub's
/// optional-auth guard remains the authority: paid content returns 403.
async fn handle_indeehub_public_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path
.strip_prefix("/api/public-indeehub-stream/")
.unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let catalog = reqwest::Client::new()
.get("http://127.0.0.1:7778/api/projects/public-catalog")
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
if !catalog.status().is_success() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
let listings: serde_json::Value = catalog.json().await?;
let listing = listings.as_array().and_then(|items| {
items
.iter()
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
});
let Some(listing) = listing else {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
));
};
if !indeehub_listing_is_publicly_free(listing) {
return Ok(build_response(
StatusCode::PAYMENT_REQUIRED,
"application/json",
hyper::Body::from(r#"{"error":"rental required"}"#),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
let content_id = value.get("id").and_then(|v| v.as_str()).unwrap_or(id);
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(storage_path) = file.strip_prefix("/storage/") {
value["file"] = serde_json::json!(format!(
"/api/public-indeehub-media/{content_id}/{storage_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
/// Proxy encrypted HLS manifests and segments from IndeeHub's public
/// bucket. The manifest key URI is rebound to the guarded free-key route.
async fn handle_indeehub_public_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/public-indeehub-media/")
.unwrap_or("");
let Some((content_id, object)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
};
let safe_id = !content_id.is_empty()
&& content_id
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-');
let safe_object = !object.is_empty()
&& !object.split('/').any(|part| part == "..")
&& object
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'));
if !safe_id || !safe_object {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/storage/{object}"))
.send()
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if object.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
&format!("/api/contents/{content_id}/key"),
&format!("/api/public-indeehub-key/{content_id}"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if object.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
/// Fetch the HLS key through IndeeHub's access check. With no forwarded
/// user credentials, only genuinely free content can succeed here.
async fn handle_indeehub_public_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path.strip_prefix("/api/public-indeehub-key/").unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid content id"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/key"))
.send()
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
async fn request_indeehub_peer(&self, onion: &str, path: &str) -> Result<reqwest::Response> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let node = nodes
.iter()
.find(|node| node.onion == onion)
.context("IndeeHub peer is not connected")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(node.fips_npub.as_deref(), &node.onion, path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(15))
.send_get()
.await?;
Ok(response)
}
/// Same-origin browser bridge for a connected peer's free stream. The
/// remote node still performs IndeeHub's free/paid authorization check.
async fn handle_indeehub_peer_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/peer-indeehub-stream/")
.unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid peer stream"}"#),
));
};
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-stream/{id}"))
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(remote_path) = file.strip_prefix("/api/public-indeehub-media/") {
value["file"] = serde_json::json!(format!(
"/api/peer-indeehub-media/{onion}/{remote_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
async fn handle_indeehub_peer_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-media/").unwrap_or("");
let Some((onion, remote_path)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer media"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-media/{remote_path}"))
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if remote_path.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
"/api/public-indeehub-key/",
&format!("/api/peer-indeehub-key/{onion}/"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if remote_path.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.body(hyper::Body::from(bytes))?)
}
async fn handle_indeehub_peer_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-key/").unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer key"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-key/{id}"))
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(bytes))?)
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
/// browser download. The archive is passphrase-encrypted at rest; the
/// session gate at the route controls who can fetch it.
@@ -318,6 +688,40 @@ impl ApiHandler {
}
}
// A node app on another port shares host-only owner cookies. Never grant
// that app access to the owner shell, even though other app APIs accept
// same-host cross-port origins. Browser terminal requests must originate
// from the exact dashboard authority (Host includes its port).
fn has_terminal_origin(headers: &hyper::HeaderMap) -> bool {
let Some(origin) = headers
.get(hyper::header::ORIGIN)
.and_then(|v| v.to_str().ok())
else {
return false;
};
let Some(host) = headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
else {
return false;
};
origin == format!("http://{host}") || origin == format!("https://{host}")
}
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
// browser-controlled header and distinguishes the dashboard from an app on
// another port, which is only same-site. Mutating requests and WebSockets
// still require the exact Origin above.
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
if headers.contains_key(hyper::header::ORIGIN) {
return Self::has_terminal_origin(headers);
}
headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
== Some("same-origin")
}
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same
@@ -434,6 +838,13 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(req.headers()) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
return Self::handle_terminal_websocket(req).await;
}
@@ -506,7 +917,25 @@ impl ApiHandler {
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
(Method::POST, "/rpc/v1") => {
let origin = self.app_cors_origin(&headers);
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
if !origin.is_empty() {
response.headers_mut().insert(
"access-control-allow-origin",
hyper::header::HeaderValue::from_str(&origin)?,
);
response.headers_mut().insert(
"access-control-allow-credentials",
hyper::header::HeaderValue::from_static("true"),
);
response.headers_mut().insert(
hyper::header::VARY,
hyper::header::HeaderValue::from_static("Origin"),
);
}
Ok(response)
}
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
// replacing the unauthenticated claude-api-proxy.py sidecar and
@@ -556,11 +985,29 @@ impl ApiHandler {
}
(Method::GET, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_read_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
terminal::list_response().await
}
(Method::POST, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
terminal::create(&body_bytes).await
}
@@ -718,6 +1165,39 @@ impl ApiHandler {
.body(hyper::Body::from(body))?)
}
// Public IndeeHub metadata is also exposed on the FIPS peer
// listener, whose path filter is the only ingress gate here.
(Method::GET, "/api/public-catalog/indeedhub") => {
self.handle_indeehub_public_catalog().await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-stream/") => {
self.handle_indeehub_public_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-media/") => {
self.handle_indeehub_public_media(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-key/") => {
self.handle_indeehub_public_key(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-stream/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-media/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_media(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-key/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_key(p).await
}
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
@@ -879,3 +1359,89 @@ fn sanitize_html(s: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
#[cfg(test)]
mod terminal_origin_tests {
use super::ApiHandler;
use hyper::header::{HeaderMap, HeaderValue, HOST, ORIGIN};
fn allowed(host: &str, origin: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
ApiHandler::has_terminal_origin(&headers)
}
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
if let Some(fetch_site) = fetch_site {
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
}
ApiHandler::has_terminal_read_origin(&headers)
}
#[test]
fn terminal_accepts_only_the_exact_dashboard_authority() {
assert!(allowed("node.local", Some("https://node.local")));
assert!(allowed("192.168.1.2:7778", Some("http://192.168.1.2:7778")));
assert!(!allowed("node.local", Some("https://node.local:7778")));
assert!(!allowed("node.local:7778", Some("https://node.local")));
assert!(!allowed("node.local", Some("https://evil.example")));
assert!(!allowed("node.local", Some("null")));
assert!(!allowed("node.local", None));
}
#[test]
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
assert!(read_allowed("node.local", None, Some("same-origin")));
assert!(!read_allowed("node.local", None, Some("same-site")));
assert!(!read_allowed("node.local", None, Some("cross-site")));
assert!(!read_allowed("node.local", None, None));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-site")
));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-origin")
));
}
}
#[cfg(test)]
mod indeehub_public_stream_tests {
use super::indeehub_listing_is_publicly_free;
use serde_json::json;
#[test]
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
assert!(indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": null
})));
assert!(indeehub_listing_is_publicly_free(&json!({
"rentalPrice": "0",
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 15,
"offerEvent": null
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": {"tags": [["rental", "invalid"]]}
})));
}
}
+13 -2
View File
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
}
let id = format!("s-{}", Uuid::new_v4().simple());
let tmux_name = format!("archy-{id}");
let output = Command::new("tmux")
let output = match Command::new("tmux")
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
.output()
.await?;
.await
{
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
));
}
Err(error) => return Err(error.into()),
};
if !output.status.success() {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
@@ -257,6 +257,12 @@ impl RpcHandler {
}
}
// Unit tests deliberately construct a handler without an orchestrator.
// Never let that mock cross into a real Podman command on the host.
if cfg!(test) && self.orchestrator.is_none() {
return Ok(serde_json::json!([]));
}
let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "json"])
.output()
+91 -12
View File
@@ -1013,19 +1013,28 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain-status/{}", content_id, address);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_content_get(&self.config.data_dir)
.await
{
Ok(v) => v,
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })),
};
let (response, _transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_content_get(&self.config.data_dir)
.await
{
Ok(v) => v,
Err(_) => {
return Ok(
serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." }),
)
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }));
return Ok(
serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }),
);
}
let body: serde_json::Value = response
.json()
@@ -1409,6 +1418,76 @@ impl RpcHandler {
}))
}
/// Browse IndeeHub's public film catalogs through the native peer bridge.
/// The bridge selects FIPS first (with the normal safe fallback policy),
/// so the browser never needs a peer LAN address or an onion connection.
pub(super) async fn handle_content_browse_indeehub_peers(&self) -> Result<serde_json::Value> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let peers: Vec<_> = nodes
.into_iter()
.filter(|node| is_valid_v3_onion(&node.onion))
.collect();
let calls = peers.into_iter().map(|node| async move {
let onion = node.onion.clone();
let request_onion = onion.clone();
let fips = node.fips_npub.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(12), async move {
let (response, transport) = crate::fips::dial::PeerRequest::new(
fips.as_deref(),
&request_onion,
"/api/public-catalog/indeedhub",
)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(10))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await?;
if !response.status().is_success() {
anyhow::bail!("peer returned {}", response.status());
}
let listings: serde_json::Value = response
.json()
.await
.context("invalid IndeeHub peer catalog")?;
Ok::<_, anyhow::Error>((listings, transport))
})
.await
.ok()
.and_then(Result::ok);
(onion, result)
});
let mut items = Vec::new();
let mut reached = 0usize;
for (onion, result) in futures_util::future::join_all(calls).await {
let Some((listings, transport)) = result else {
continue;
};
let Some(listings) = listings.as_array() else {
continue;
};
reached += 1;
for listing in listings {
let Some(mut listing) = listing.as_object().cloned() else {
continue;
};
listing.insert("peer".to_string(), serde_json::json!(onion));
listing.insert(
"transport".to_string(),
serde_json::json!(transport.to_string()),
);
items.push(serde_json::Value::Object(listing));
}
}
Ok(serde_json::json!({
"items": items,
"peers_reached": reached,
"peers_total": reached,
"partial": false,
}))
}
/// `content.owned-get` — return a purchased item's bytes (base64) from the
/// local cache for in-app viewing/saving. No network, no re-payment.
pub(super) async fn handle_content_owned_get(
@@ -354,6 +354,7 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.browse-indeehub-peers" => self.handle_content_browse_indeehub_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
@@ -114,17 +114,34 @@ impl RpcHandler {
/// Explicit owner-key import into a separate native business identity.
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
&self, params: Option<serde_json::Value>,
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or("");
let password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("");
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Invalid node password");
}
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works");
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim();
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or("");
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Just Works");
anyhow::ensure!(
!name.trim().is_empty() && name.len() <= 100,
"Invalid identity name"
);
let nsec = params
.get("nsec")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let npub = params
.get("expected_npub")
.and_then(|v| v.as_str())
.unwrap_or("");
let manager = IdentityManager::new(&self.config.data_dir).await?;
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
Ok(serde_json::json!({"id":record.id, "name":record.name,
@@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolvePara
let encoded = serde_json::to_value(event)?;
anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_u64() <= now.saturating_add(30),
&& event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_secs() <= now.saturating_add(30),
"Invalid resolution signature time or scope"
);
let content: serde_json::Value = serde_json::from_str(&event.content)?;
@@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result<String> {
producer == params.intent.producer,
"The signing identity differs from the project producer"
);
let created = event.created_at.as_u64();
let created = event.created_at.as_secs();
anyhow::ensure!(
created >= params.intent.created_at.saturating_sub(30)
&& created < params.intent.expires_at
@@ -201,12 +201,12 @@ impl RpcHandler {
anyhow::ensure!(
state
.package_data
.get("indeedhub-api")
.get("indeedhub")
.is_some_and(|entry| matches!(
entry.state,
crate::data_model::PackageState::Running
)),
"The installed IndeeHub API must be running to register its media"
"The installed IndeeHub app must be running to register its media"
);
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
+2 -3
View File
@@ -18,10 +18,10 @@ mod handshake;
mod identity;
mod interfaces;
mod lightning_purchase;
mod onchain_purchase;
pub(crate) mod lnd;
mod marketplace;
mod media_registration;
mod onchain_purchase;
mod playback;
mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
@@ -34,12 +34,12 @@ mod monitoring;
mod music;
mod names;
mod network;
mod publishing;
mod node;
mod nostr;
mod onboarding_gate;
mod openwrt;
mod package;
mod publishing;
pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
@@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "media.registration.context"
| "media.registration.resolve"
| "content.rental-purchase"
| "content.onchain-cancel"
| "content.onchain-attempt"
| "content.onchain-create"
@@ -442,7 +442,7 @@ impl RpcHandler {
if record.quote.is_none() {
engine::mark_address_allocation(&journal, true)?;
let status = self.request_onchain_allocation(&record, &fips).await?;
record = engine::accept_quote(
engine::accept_quote(
&journal,
status.quote()?.context(
"Original seller allocation is unresolved; recover this operation",
+32 -16
View File
@@ -74,10 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
fn patched_indeedhub_nginx_config(original: &str) -> String {
fn patched_indeedhub_nginx_config(original: &str, inject_provider: bool) -> String {
let mut conf = original
.lines()
.filter(|line| !line.contains("X-Frame-Options"))
.filter(|line| {
!line.contains("X-Frame-Options")
&& (inject_provider
|| (!line.contains("sub_filter_once")
&& !line.contains("sub_filter '</head>'")))
})
.collect::<Vec<_>>()
.join("\n");
conf.push('\n');
@@ -91,21 +96,22 @@ fn patched_indeedhub_nginx_config(original: &str) -> String {
location = /sw.js {",
);
}
if conf.contains("try_files") && !conf.contains("sub_filter") {
if inject_provider && conf.contains("try_files") && !conf.contains("sub_filter") {
conf = conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v5\"></script></head>';",
1,
);
}
conf = conf.replace(
"src=\"/nostr-provider.js\"",
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
"src=\"/nostr-provider.js?v=tab-signer-v5\"",
);
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
conf = conf.replace("tab-signer-v2", "tab-signer-v5");
conf = conf.replace("tab-signer-v3", "tab-signer-v5");
conf = conf.replace("tab-signer-v4", "tab-signer-v5");
conf.replace(
"proxy_set_header X-Forwarded-Prefix /api;",
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
@@ -165,6 +171,11 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
let provider_is_bundled =
tokio::fs::read_to_string(format!("{container_root}/usr/share/nginx/html/index.html"))
.await
.map(|html| html.contains("src=\"/nostr-provider.js"))
.unwrap_or(false);
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
&& tokio::process::Command::new("podman")
.args([
@@ -189,7 +200,7 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
if let Ok(out) = copy_out {
if out.status.success() {
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
let conf = patched_indeedhub_nginx_config(&original);
let conf = patched_indeedhub_nginx_config(&original, !provider_is_bundled);
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
config_copied = tokio::process::Command::new("podman")
.args([
@@ -219,7 +230,8 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
}
} else if conf == original
&& conf.contains("location = /nostr-provider.js {")
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
&& (provider_is_bundled
|| conf.contains("src=\"/nostr-provider.js?v=tab-signer-v5\""))
{
config_copied = true;
}
@@ -2618,19 +2630,23 @@ mod tests {
}
}
"#;
let patched = patched_indeedhub_nginx_config(original);
let patched = patched_indeedhub_nginx_config(original, true);
assert!(!patched.contains("X-Frame-Options"));
assert!(patched.contains("location = /nostr-provider.js {"));
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\""));
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v5\""));
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
assert_eq!(patched_indeedhub_nginx_config(&patched, true), patched);
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
let migrated = patched_indeedhub_nginx_config(&previous_broker);
assert!(migrated.contains("tab-signer-v4"));
let previous_broker = patched.replace("tab-signer-v5", "tab-signer-v3");
let migrated = patched_indeedhub_nginx_config(&previous_broker, true);
assert!(migrated.contains("tab-signer-v5"));
assert!(!migrated.contains("tab-signer-v3"));
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
assert_eq!(patched_indeedhub_nginx_config(&migrated, true), migrated);
let bundled = patched_indeedhub_nginx_config(&patched, false);
assert!(!bundled.contains("sub_filter"));
assert_eq!(patched_indeedhub_nginx_config(&bundled, false), bundled);
}
#[test]
@@ -883,7 +883,8 @@ async fn do_orchestrator_package_start(
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
if !managed {
repair_before_package_start(name).await;
wait_before_package_start(name).await;
@@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop(
) -> Result<()> {
let mut errors = Vec::new();
for name in containers {
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
match orchestrator.stop(name).await {
Ok(()) => {}
Err(e) if !managed && is_unknown_app_id_error(&e) => {
@@ -1977,9 +1979,9 @@ pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
// Cargo exposes this at compile time, not when an already-built isolated
// test executable is launched from a different working directory.
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([
Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(),
+5 -1
View File
@@ -146,7 +146,11 @@ impl RpcHandler {
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(20))
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
.send_rental_control_json(
&self.config.data_dir,
&binding.contract.seller_did,
&body,
)
.await?;
anyhow::ensure!(
response.status().is_success(),
+42
View File
@@ -144,6 +144,8 @@ impl RpcHandler {
struct RentalParams {
#[serde(default)]
retry_preparation: bool,
#[serde(default)]
renew_expired: bool,
seller_did: String,
content_id: String,
expected_sha256: String,
@@ -209,6 +211,46 @@ impl RpcHandler {
price_sats: params.expected_price_sats,
viewing_seconds: params.expected_viewing_seconds,
};
if params.renew_expired {
let journal = Journal::open(&self.config.data_dir).await?;
let matching = journal
.find_buyers(&buyer, transport.seller_did(), &params.content_id)
.await?;
let settled: Vec<_> = matching
.into_iter()
.filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved)
.collect();
anyhow::ensure!(
settled.len() <= 1,
"Multiple original rentals require recovery"
);
if let Some(record) = settled.into_iter().next() {
let capability = record
.receipt()
.context("Original rental receipt is missing")?
.capability
.clone();
let original_duration = journal
.protocol_envelope("buyer", &record.contract.id)
.await?
.context("Original rental terms are missing")?
.offer
.viewing_seconds
.context("Original purchase is not a timed rental")?;
let (started, expires) = transport
.expired_rental_window(&record.contract, &capability, original_duration)
.await?;
journal
.record_rental_expiry(
&record.contract,
started,
expires,
original_duration,
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0),
)
.await?;
}
}
match caller::purchase_bound(
&self.config.data_dir,
&buyer,
+3 -3
View File
@@ -119,9 +119,9 @@ fn apps_dirs() -> Vec<PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
// Preserve source discovery after the prebuilt test binary moves into its
// networkless execution container.
dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([
PathBuf::from("apps"),
PathBuf::from("/opt/archipelago/apps"),
+3 -2
View File
@@ -23,7 +23,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use rand::RngCore;
use serde_json::json;
use sha2::{Digest, Sha256};
use tokio::sync::oneshot;
@@ -227,7 +226,9 @@ pub fn global() -> Arc<ConfirmGate> {
/// nonce for the same action never matches today's pending entry).
pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt).unwrap_or_else(|e| {
panic!("refusing to mint a confirmation nonce from degenerate entropy: {e} (KEY-05)")
});
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(tool_name.as_bytes());
+1 -1
View File
@@ -30,7 +30,7 @@ pub const TOKEN_LEN: usize = 8;
/// wrapped. Called exactly once per [`UntrustedBlock::new`] /
/// [`wrap_untrusted`] invocation.
fn fresh_token() -> String {
rand::thread_rng()
rand::rngs::OsRng
.sample_iter(Alphanumeric)
.take(TOKEN_LEN)
.map(char::from)
@@ -553,6 +553,24 @@ mod lifecycle_regression_tests {
assert_eq!(entry.static_files.license, "");
}
#[test]
fn manifest_presentation_reports_the_declared_author() {
let mut entry = installing_fixture();
entry.manifest.author = Some("Archipelago".to_owned());
for (metadata, expected) in [
(serde_json::json!({}), "Archipelago"),
(serde_json::json!({"author":" "}), "Archipelago"),
(serde_json::json!({"author":3}), "Archipelago"),
(serde_json::json!({"author":" ForgeSworn "}), "ForgeSworn"),
] {
apply_manifest_value(
&serde_json::json!({"app":{"metadata":metadata}}),
&mut entry,
);
assert_eq!(entry.manifest.author.as_deref(), Some(expected));
}
}
#[test]
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
let mut entry = installing_fixture();
@@ -882,6 +900,10 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
if let Some(icon) = text(metadata.get("icon")) {
entry.static_files.icon = icon;
}
// The scanner's default author is the node OS; a declared author wins.
if let Some(author) = text(metadata.get("author")) {
entry.manifest.author = Some(author);
}
if let Some(tier) = text(metadata.get("tier")) {
entry.manifest.tier = Some(tier);
}
@@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Left("user-uninstalled".into()));
}
self.sync_quadlet_unit(lm, &managed_name).await?;
let status = self.runtime.get_container_status(&managed_name).await
let status = self
.runtime
.get_container_status(&managed_name)
.await
.context("Reviewed managed runtime is missing; explicit recovery required")?;
anyhow::ensure!(matches!(status.state, ContainerState::Running),
"Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog");
@@ -4019,36 +4022,38 @@ impl ProdContainerOrchestrator {
.clone()
.unwrap_or_else(|| "bitcoin-knots".to_string());
}
#[allow(unreachable_code)]
// The known Bitcoin node containers, preferred in order. Any archy
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
// (or bare `bitcoin`), all reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
// Prefer a known name in priority order…
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
#[cfg(not(test))]
{
// The known Bitcoin node containers, preferred in order. Any archy
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
// (or bare `bitcoin`), all reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
// Prefer a known name in priority order…
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
}
#[cfg(test)]
@@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
};
let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery");
anyhow::ensure!(
!super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?;
self.ensure_resolved_source_available(&lm).await?;
}
@@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery");
anyhow::ensure!(
!super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?;
quadlet::stop_service(&format!("{name}.service")).await?;
if let Ok(status) = self.runtime.get_container_status(&name).await {
anyhow::ensure!(
matches!(status.state, ContainerState::Stopped | ContainerState::Exited | ContainerState::Created),
matches!(
status.state,
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created
),
"Reviewed managed runtime is still active after systemd stop"
);
}
@@ -5391,7 +5403,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> {
if let Ok(lm) = self.loaded(app_id).await {
if super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_some() {
if super::supervised_update::installed_unit(
&self.data_dir,
&compute_container_name(&lm.manifest),
)?
.is_some()
{
self.validate_start(app_id).await?;
self.stop(app_id).await?;
return self.start(app_id).await;
@@ -7966,27 +7983,59 @@ app:
#[tokio::test]
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
use std::os::unix::fs::PermissionsExt;
for case in ["running", "stopped", "missing", "changed-unit", "missing-unit", "user-stopped", "user-uninstalled"] {
for case in [
"running",
"stopped",
"missing",
"changed-unit",
"missing-unit",
"user-stopped",
"user-uninstalled",
] {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
let mut manifest = pull_manifest(&name, "catalog:new");
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")).await;
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift"))
.await;
let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n";
let records = orch.data_dir.join("update-transactions/installed-units");
std::fs::create_dir_all(&records).unwrap();
std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": body, "mode": 0o600
})).unwrap()).unwrap();
let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container"));
std::fs::write(
records.join(format!("{name}.json")),
serde_json::to_vec(&serde_json::json!({
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": body, "mode": 0o600
}))
.unwrap(),
)
.unwrap();
let unit = quadlet::unit_dir()
.await
.unwrap()
.join(format!("{name}.container"));
if case != "missing-unit" {
std::fs::write(&unit, if case == "changed-unit" { "operator changed" } else { body }).unwrap();
std::fs::write(
&unit,
if case == "changed-unit" {
"operator changed"
} else {
body
},
)
.unwrap();
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
}
if case != "missing" {
rt.set_state(&name, if case == "stopped" { ContainerState::Stopped } else { ContainerState::Running });
rt.set_state(
&name,
if case == "stopped" {
ContainerState::Stopped
} else {
ContainerState::Running
},
);
}
if case == "user-stopped" {
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
@@ -8010,7 +8059,13 @@ app:
assert!(result.is_err(), "{case} must refuse before mutation");
}
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:")), "{case}: {:?}", rt.calls());
assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")),
"{case}: {:?}",
rt.calls()
);
if case == "running" {
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
}
@@ -8026,8 +8081,14 @@ app:
orch.validate_start(&name).await.unwrap();
orch.start(&name).await.unwrap();
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir).await.contains(&name));
assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name));
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir)
.await
.contains(&name));
assert!(
!crate::crash_recovery::load_user_uninstalled(&orch.data_dir)
.await
.contains(&name)
);
} else {
// Missing images/units and modified units refuse an explicit
// start before service mutation; no catalog pull is attempted.
@@ -8037,7 +8098,14 @@ app:
assert!(orch.stop(&name).await.is_err());
}
}
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:") || call.starts_with("image_exists:")), "{case}: {:?}", rt.calls());
assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")
|| call.starts_with("image_exists:")),
"{case}: {:?}",
rt.calls()
);
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
let _ = std::fs::remove_file(unit);
}
@@ -8664,9 +8732,11 @@ app:
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
// Repeated passes must leave lifecycle ownership with companion.rs.
for _ in 0..3 {
for pass in 0..3 {
let report = orch.reconcile_existing().await;
assert_eq!(report.actions.len(), companions.len());
if pass == 0 {
assert_eq!(report.actions.len(), companions.len());
}
assert!(report
.actions
.iter()
+45 -34
View File
@@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result<PathBuf> {
.get_or_init(|| tempfile::tempdir().unwrap().keep())
.clone());
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
let dir = home.join(DEFAULT_REL_UNIT_DIR);
fs::create_dir_all(&dir)
.await
.with_context(|| format!("create_dir_all {}", dir.display()))?;
Ok(dir)
#[cfg(not(test))]
{
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
let dir = home.join(DEFAULT_REL_UNIT_DIR);
fs::create_dir_all(&dir)
.await
.with_context(|| format!("create_dir_all {}", dir.display()))?;
Ok(dir)
}
}
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
@@ -944,21 +947,25 @@ async fn systemctl_user_status(
#[cfg(test)]
{
use std::os::unix::process::ExitStatusExt;
let _ = (args, timeout);
return Ok(std::process::ExitStatus::from_raw(0));
}
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.status())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
#[cfg(not(test))]
{
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.status())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
}
async fn kill_and_reset_service(service: &str) -> Result<()> {
@@ -994,21 +1001,25 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
#[cfg(test)]
{
let _ = (args, timeout);
anyhow::bail!("Unit tests have no real user service manager");
}
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
#[cfg(not(test))]
{
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
}
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
@@ -48,6 +48,12 @@ pub(crate) fn installed_context(
state: &crate::data_model::DataModel,
) -> Result<InstalledAppContext> {
for id in ["indeedhub", "indeedhub-api"] {
// Stack components are not separate package records on managed installs.
// Keep validating legacy standalone API records when they exist; the
// installed parent and its existing API identity pin are mandatory.
if id == "indeedhub-api" && !state.package_data.contains_key(id) {
continue;
}
let entry = state
.package_data
.get(id)
@@ -613,6 +619,12 @@ mod tests {
assert!(installed_context(root.path(), &identity, &state).is_err());
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
let first = installed_context(root.path(), &identity, &state).unwrap();
let api_entry = state.package_data.remove("indeedhub-api").unwrap();
assert_eq!(
installed_context(root.path(), &identity, &state).unwrap(),
first
);
state.package_data.insert("indeedhub-api".into(), api_entry);
assert_eq!(first.app_audience, pin.app_audience);
assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
state.package_data.get_mut("indeedhub-api").unwrap().state =
+77
View File
@@ -1167,6 +1167,40 @@ impl Journal {
}
Ok(record)
}
/// Retire a settled timed-rental receipt only after the authenticated
/// seller has reported its immutable viewing window as expired. This is a
/// lifecycle transition, not evidence that a permanent copy was cached:
/// it merely permits a later, explicitly approved rental operation for the
/// same registered title. Incomplete and still-active purchases must remain
/// recoverable under their original UUID.
pub async fn record_rental_expiry(
&self,
contract: &Contract,
started_at: u64,
expires_at: u64,
viewing_seconds: u64,
now: u64,
) -> Result<BuyerRecord> {
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
contract.content_id.starts_with("registered_")
&& record.phase == BuyerPhase::ReceiptSaved
&& record.receipt.is_some(),
"Only a settled timed rental can be retired"
);
anyhow::ensure!(
viewing_seconds > 0
&& started_at > 0
&& started_at.checked_add(viewing_seconds) == Some(expires_at)
&& now >= expires_at,
"Original rental viewing period has not ended"
);
record.phase = BuyerPhase::Delivered;
record.validate()?;
self.write("buyer", &contract.id, &record).await?;
Ok(record)
}
}
#[cfg(test)]
@@ -1365,6 +1399,49 @@ mod tests {
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
}
#[tokio::test]
async fn only_an_expired_settled_rental_can_be_retired_for_another_period() {
let root = tempfile::tempdir().unwrap();
let mut rental = contract();
rental.content_id = "registered_film-1".into();
let journal = Journal::open(root.path()).await.unwrap();
journal.prepare_buyer(&rental, 1100).await.unwrap();
let seller = journal.prepare_seller(&rental, 1100).await.unwrap();
journal
.record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did)
.await
.unwrap();
let encoded = token(&rental, "rental-expiry");
journal.record_token(&rental, &encoded).await.unwrap();
journal
.record_incoming_token(&rental, &encoded)
.await
.unwrap();
journal
.record_settlement(&rental, rental.minimum_net_sats)
.await
.unwrap();
let receipt = journal.issue_receipt(&rental).await.unwrap();
journal.record_receipt(&rental, &receipt).await.unwrap();
assert!(journal
.record_rental_expiry(&rental, 2000, 5600, 3600, 5599)
.await
.is_err());
assert_eq!(
journal.buyer(&rental.id).await.unwrap().unwrap().phase,
BuyerPhase::ReceiptSaved
);
let retired = journal
.record_rental_expiry(&rental, 2000, 5600, 3600, 5600)
.await
.unwrap();
assert_eq!(retired.phase, BuyerPhase::Delivered);
let mut next = rental.clone();
next.id = uuid::Uuid::new_v4().to_string();
journal.prepare_buyer(&next, 1500).await.unwrap();
}
#[tokio::test]
async fn damaged_records_and_nonregular_targets_are_preserved() {
let root = tempfile::tempdir().unwrap();
let contract = contract();
@@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound(
.await?;
let unresolved: Vec<_> = matching
.into_iter()
.filter(|record| record.phase != BuyerPhase::Cancelled)
.filter(|record| {
record.phase != BuyerPhase::Cancelled
&& !(expected.is_some() && record.phase == BuyerPhase::Delivered)
})
.collect();
anyhow::ensure!(
unresolved.len() <= 1,
@@ -39,6 +39,60 @@ impl FipsPurchaseTransport {
self.retry_preparation = retry;
self
}
/// Ask the authenticated seller for the original rental window without
/// starting or extending it. The capability never crosses into the app.
pub async fn expired_rental_window(
&self,
contract: &crate::content_purchase::Contract,
capability: &str,
viewing_seconds: u64,
) -> Result<(u64, u64)> {
let route = format!(
"/content/{}/rental/{}/prepare",
contract.content_id, contract.id
);
let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false});
let (mut response, _) =
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route)
.require_fips()
.single_delivery()
.timeout(Duration::from_secs(20))
.send_rental_control_json(&self.data_dir, &self.seller_did, &body)
.await?;
anyhow::ensure!(
response.status().is_success(),
"Original rental status is unavailable"
);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 16 * 1024),
"Rental status response is too large"
);
bytes.extend_from_slice(&chunk);
}
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
value["state"] == "expired",
"Original rental viewing period is still available"
);
let started = value["started_at"]
.as_u64()
.context("Missing rental start")?;
let expires = value["expires_at"]
.as_u64()
.context("Missing rental expiry")?;
anyhow::ensure!(
value["viewing_seconds"].as_u64() == Some(viewing_seconds)
&& started.checked_add(viewing_seconds) == Some(expires),
"Seller changed the original rental window"
);
Ok((started, expires))
}
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
&self,
route: &str,
+1 -4
View File
@@ -171,10 +171,7 @@ pub(crate) fn prepare(
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
()
}
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
Err(error) => return Err(error),
}
let reservation = crate::snapshot_budget::reserve(
+69 -1
View File
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
fn is_rental_control_route(path: &str) -> bool {
let parts: Vec<_> = path.split('/').collect();
let valid_id = |value: &str| {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
};
parts.len() == 6
&& parts[0].is_empty()
&& parts[1] == "content"
&& valid_id(parts[2])
&& parts[3] == "rental"
&& valid_id(parts[4])
&& matches!(parts[5], "prepare" | "start")
}
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
request.send_encoded_json(&encoded).await
}
/// Rental preparation/start carries an already-settled capability, never a
/// payment token. Authenticate the exact control route without relaxing the
/// purchase-only guard above or replaying any purchase on a retry.
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
anyhow::ensure!(
is_rental_control_route(self.path),
"Not a rental control route"
);
let (request, encoded) = self
.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await?;
request.send_encoded_json(&encoded).await
}
async fn prepare_content_json<B: serde::Serialize>(
mut self,
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
self.path.starts_with("/content/purchase/"),
"Not a purchase route"
);
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await
}
async fn prepare_authenticated_content_json<B: serde::Serialize>(
mut self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(Self, Vec<u8>)> {
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
anyhow::ensure!(
peer.did == expected_seller,
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
mod tests {
use super::*;
#[test]
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/prepare"
));
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/start"
));
for path in [
"/content/purchase/abc",
"/content/registered_abc/rental/1234-5678/settle",
"/content/registered_abc/rental/1234-5678/prepare/extra",
"/content/../rental/1234-5678/prepare",
"/content/registered_abc/rental/1234-5678/prepare?token=x",
] {
assert!(
!is_rental_control_route(path),
"unexpected rental control route: {path}"
);
}
}
#[tokio::test]
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
use std::sync::atomic::{AtomicUsize, Ordering};
+1 -1
View File
@@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
pub fn fips0_ula() -> Option<Ipv6Addr> {
addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a))
addresses_on(FIPS_IFACE).into_iter().find(is_ula)
}
/// List every IPv6 address bound to a given interface from
+17 -7
View File
@@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
if matches!(
pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating
PackageState::Starting
| PackageState::Stopping
| PackageState::Restarting
| PackageState::Updating
) {
debug!(
"Skipping container during package lifecycle transition: {} ({:?})",
@@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
app_id: Some(container.app_id.clone()),
});
if data.notifications.len() > 20 {
data.notifications = data.notifications.split_off(data.notifications.len() - 20);
data.notifications =
data.notifications.split_off(data.notifications.len() - 20);
}
state_changed = true;
}
@@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
// the restart resyncs cleanly instead of crash-looping.
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
let restarted = restart_container(&container.name, &container.state, &data_dir).await;
let restarted =
restart_container(&container.name, &container.state, &data_dir).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
let notification = Notification {
@@ -1249,10 +1254,15 @@ mod tests {
let installed = root.path().join("update-transactions/installed-units");
std::fs::create_dir_all(&installed).unwrap();
let record = installed.join(format!("{name}.json"));
std::fs::write(&record, serde_json::to_vec(&serde_json::json!({
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
})).unwrap()).unwrap();
std::fs::write(
&record,
serde_json::to_vec(&serde_json::json!({
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
}))
.unwrap(),
)
.unwrap();
assert!(!automatic_recovery_allowed(root.path(), name));
assert!(!restart_container(name, "running", root.path()).await);
std::fs::write(&record, b"damaged").unwrap();
+70 -21
View File
@@ -205,14 +205,22 @@ impl IdentityManager {
nsec: &str,
expected_npub: &str,
) -> Result<IdentityRecord> {
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key");
let secret = nostr_sdk::SecretKey::parse(nsec)
.map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
anyhow::ensure!(
!name.trim().is_empty() && name.len() <= 100,
"Invalid identity name"
);
anyhow::ensure!(
nsec.starts_with("nsec1") && nsec.len() == 63,
"Enter a plain nsec owner key"
);
let secret =
nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
let keys = nostr_sdk::Keys::new(secret);
let nostr_pubkey = keys.public_key().to_hex();
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub,
"Owner key does not match this website");
anyhow::ensure!(
keys.public_key().to_bech32()? == expected_npub,
"Owner key does not match this website"
);
// Serializes imports only; mature creation/signing paths are untouched.
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -260,7 +268,8 @@ impl IdentityManager {
// Atomic publication, and unlike rename this cannot replace a file.
fs::hard_link(&staging, &destination).await?;
Ok(())
}.await;
}
.await;
let _ = fs::remove_file(&staging).await;
write_result.context("Could not save imported identity")?;
self.get(&id).await
@@ -974,23 +983,53 @@ mod tests {
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
let dir = tempdir().unwrap();
let manager = IdentityManager::new(dir.path()).await.unwrap();
let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap();
let original = manager
.create("Personal".into(), IdentityPurpose::Personal)
.await
.unwrap();
let keys = nostr_sdk::Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let npub = keys.public_key().to_bech32().unwrap();
assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err());
assert!(manager
.import_nostr("Wrong".into(), &nsec, "npub1wrong")
.await
.is_err());
assert_eq!(manager.list().await.unwrap().0.len(), 1);
let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap();
let imported = manager
.import_nostr("Website".into(), &nsec, &npub)
.await
.unwrap();
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id);
assert_eq!(
manager
.import_nostr("Again".into(), &nsec, &npub)
.await
.unwrap()
.id,
imported.id
);
let (records, default) = manager.list().await.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(default.as_deref(), Some(original.id.as_str()));
assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey);
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec);
#[cfg(unix)] {
assert_eq!(
manager.get(&original.id).await.unwrap().nostr_pubkey,
original.nostr_pubkey
);
assert_eq!(
manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"],
nsec
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode();
let mode = std::fs::metadata(
dir.path()
.join("identities")
.join(format!("{}.json", imported.id)),
)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
@@ -1012,14 +1051,24 @@ mod tests {
assert_eq!(records.len(), 1);
assert!(default.is_none());
let hash = [7u8; 32];
let signature = manager.nostr_sign(&first.id, &hex::encode(hash)).await.unwrap();
let signature = manager
.nostr_sign(&first.id, &hex::encode(hash))
.await
.unwrap();
let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap();
nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr(
&signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey,
).unwrap();
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey =
keys.public_key().to_hex().parse().unwrap();
nostr_sdk::secp256k1::Secp256k1::verification_only()
.verify_schnorr(
&signature,
&nostr_sdk::secp256k1::Message::from_digest(hash),
&pubkey,
)
.unwrap();
let entries = std::fs::read_dir(dir.path().join("identities")).unwrap();
assert!(entries.map(|entry| entry.unwrap().file_name()).all(|name| !name.to_string_lossy().ends_with(".tmp")));
assert!(entries
.map(|entry| entry.unwrap().file_name())
.all(|name| !name.to_string_lossy().ends_with(".tmp")));
}
#[tokio::test]
+1 -1
View File
@@ -77,7 +77,6 @@ mod monitoring;
mod music;
mod names;
mod network;
mod publishing;
mod node_message;
mod nostr_discovery;
mod nostr_handshake;
@@ -87,6 +86,7 @@ mod nostr_security_tests;
mod peers;
mod port_allocator;
mod prepared_media;
mod publishing;
mod rate_limit;
mod registered_media;
mod rental_chunk_index;
+1 -4
View File
@@ -683,10 +683,7 @@ pub fn resolve(
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
()
}
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
Err(error) => return Err(error),
}
}
+7 -10
View File
@@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
/// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop {
match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()),
Err(e) => last_err = Some(e),
}
if tokio::time::Instant::now() >= deadline {
break;
Err(error) if tokio::time::Instant::now() >= deadline => {
return Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
));
}
Err(_) => {}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
/// Live state for the one flash job that can run at a time. A single global
+1 -1
View File
@@ -15,7 +15,7 @@ mod frames;
mod node_cmd;
mod session;
pub(crate) use session::{probe_device, DeviceProbe};
pub(crate) use session::probe_device;
use super::types::*;
use serde::{Deserialize, Serialize};
+30 -11
View File
@@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
}
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
/// Format after the response code has been removed:
/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) +
/// txt_type(1B) + timestamp(4B) + text.
/// Returns (channel_idx, text).
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
if data.len() < 10 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..])
let channel_idx = data[3];
// data[0] = SNR, data[1..3] = reserved, data[4] = path_len,
// data[5] = txt_type, data[6..10] = timestamp.
let text = if data.len() > 10 {
String::from_utf8_lossy(&data[10..])
.trim_end_matches('\0')
.to_string()
} else {
@@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
if data.len() < 10 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
let channel_idx = data[3];
let payload = if data.len() > 10 {
let mut p = data[10..].to_vec();
// Strip trailing NUL bytes
while p.last() == Some(&0) {
p.pop();
@@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
mod tests {
use super::*;
#[test]
fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> {
// decode_frame removes the 0x11 response code before this parser sees
// the payload. A real V3 frame then begins with SNR + two reserved
// bytes; treating SNR as the channel index filed public messages under
// random channels such as 47 or 208.
let data = [
0xd0, 0x00, 0x00, // SNR and reserved
0x00, 0xff, 0x00, // public channel, path, text type
0x78, 0x56, 0x34, 0x12, // timestamp
b'h', b'e', b'l', b'l', b'o', 0x00,
];
assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string()));
assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec()));
Ok(())
}
#[test]
fn test_encode_frame() {
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
+20 -7
View File
@@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
};
let mut command = tokio::process::Command::new("df");
command.args(["--block-size=1", "--output=used,size", target]);
let output = bounded_output(command, std::time::Duration::from_secs(3)).await
let output = bounded_output(command, std::time::Duration::from_secs(3))
.await
.context("Failed to run df")?;
if !output.status.success() {
@@ -222,7 +223,8 @@ async fn bounded_output(
) -> Result<std::process::Output> {
command.kill_on_drop(true);
tokio::time::timeout(timeout, command.output())
.await.context("Metrics subprocess timed out")?
.await
.context("Metrics subprocess timed out")?
.context("Metrics subprocess failed")
}
@@ -230,7 +232,8 @@ async fn bounded_output(
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
let mut command = tokio::process::Command::new("podman");
command.args(["stats", "--no-stream", "--format", "json"]);
let output = bounded_output(command, std::time::Duration::from_secs(8)).await
let output = bounded_output(command, std::time::Duration::from_secs(8))
.await
.context("Failed to run podman stats")?;
if !output.status.success() {
@@ -411,14 +414,22 @@ mod subprocess_deadline_tests {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let mut command = tokio::process::Command::new("sh");
command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file);
command
.arg("-c")
.arg("echo $$ > \"$1\"; exec sleep 30")
.arg("metrics-test")
.arg(&pid_file);
let start = std::time::Instant::now();
let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err();
let error = bounded_output(command, std::time::Duration::from_millis(500))
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"));
assert!(start.elapsed() < std::time::Duration::from_secs(3));
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
for _ in 0..40 {
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; }
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("Timed-out metrics subprocess was not reaped");
@@ -428,7 +439,9 @@ mod subprocess_deadline_tests {
async fn successful_metrics_output_is_preserved() {
let mut command = tokio::process::Command::new("printf");
command.arg("metrics-ok");
let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap();
let output = bounded_output(command, std::time::Duration::from_secs(1))
.await
.unwrap();
assert!(output.status.success());
assert_eq!(output.stdout, b"metrics-ok");
}
+5 -3
View File
@@ -40,10 +40,12 @@ pub fn spawn_metrics_collector(
store.push(snapshot).await;
debug!("Metrics snapshot collected");
// Reconcile even when all thresholds have recovered, so
// the previous disk/RAM/CPU warning disappears promptly.
if let Some(ref state_mgr) = state {
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
if !alerts.is_empty() {
if let Some(ref state_mgr) = state {
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
if let Some(ref dir) = data_dir {
notifications::deliver_alert_webhooks(dir, &alerts).await;
}
@@ -14,7 +14,11 @@ pub(crate) async fn push_alert_notifications(
alerts: &[FiredAlert],
) {
let (mut data, _rev) = state_mgr.get_snapshot().await;
let previous_count = data.notifications.len();
prune_stale_alert_notifications(&mut data.notifications, alerts);
if alerts.is_empty() && data.notifications.len() == previous_count {
return;
}
for alert in alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
@@ -42,7 +46,17 @@ pub(crate) async fn push_alert_notifications(
data.notifications.remove(0);
}
state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len());
if !alerts.is_empty() {
info!("Fired {} alert(s)", alerts.len());
}
}
fn is_metrics_alert_id(id: &str) -> bool {
id.split_once('-').is_some_and(|(kind, timestamp)| {
matches!(kind, "disk" | "ram" | "cpu" | "latency")
&& !timestamp.is_empty()
&& timestamp.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn prune_stale_alert_notifications(
@@ -55,6 +69,11 @@ fn prune_stale_alert_notifications(
if active_ids.contains(notification.id.as_str()) {
return false;
}
// A fresh timestamp is not evidence that an earlier threshold is
// still exceeded. Remove resolved monitoring alerts immediately.
if is_metrics_alert_id(&notification.id) {
return false;
}
if notification.app_id.is_some() || notification.id.starts_with("health-") {
return true;
}
@@ -127,6 +146,8 @@ mod tests {
notification("alert-active", fresh_timestamp.clone(), None),
notification("alert-old", old_timestamp, None),
notification("alert-fresh", fresh_timestamp.clone(), None),
notification("disk-123", fresh_timestamp.clone(), None),
notification("ram-123", fresh_timestamp.clone(), None),
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
];
@@ -135,4 +156,22 @@ mod tests {
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
}
#[test]
fn clears_resolved_metrics_alerts_without_touching_other_notifications() {
let fresh = Utc::now().to_rfc3339();
let mut notifications = vec![
notification("disk-123", fresh.clone(), None),
notification("latency-456", fresh.clone(), None),
notification("disk-not-a-timestamp", fresh.clone(), None),
notification("health-indeedhub-1", fresh.clone(), Some("indeedhub")),
notification("app-notice", fresh, Some("indeedhub")),
];
prune_stale_alert_notifications(&mut notifications, &[]);
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(
ids,
vec!["disk-not-a-timestamp", "health-indeedhub-1", "app-notice"]
);
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ impl MetricsStore {
// Use saturating semantics to avoid underflow
let _ = self
.ws_connections
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
.try_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 {
Some(v - 1)
} else {
+4 -3
View File
@@ -227,7 +227,7 @@ pub async fn publish_presence(
// NIP-40 expiration: relays that honour it garbage-collect the event if
// this node stops heartbeating (reinstall, decommission, long outage).
// `discover` enforces the same window client-side for relays that don't.
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), content)
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
@@ -268,7 +268,7 @@ pub async fn publish_tombstone(
}
// Tombstone also expires: after TTL the relay may drop it entirely,
// which is the desired end state (nothing left to list).
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
@@ -326,7 +326,8 @@ pub async fn discover_nodes(
client.disconnect().await;
let mut nodes = Vec::new();
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
let stale_cutoff =
Timestamp::from(Timestamp::now().as_secs().saturating_sub(PRESENCE_TTL_SECS));
for event in events {
// Client-side staleness enforcement: pre-TTL events (and events from
// relays that ignore NIP-40) would otherwise list dead installs
+20 -2
View File
@@ -1194,11 +1194,13 @@ impl Server {
// Podman needs and can restart-loop apps that publish those ports.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let publishing_task = tokio::spawn(crate::publishing::serving::run(
self._config.data_dir.clone(), tx.subscribe(),
self._config.data_dir.clone(),
tx.subscribe(),
));
let publishing_tor_task = tokio::spawn(crate::publishing::tor::run(
self._config.data_dir.clone(), tx.subscribe(),
self._config.data_dir.clone(),
tx.subscribe(),
));
// The app gate: authentication in front of every app port, on every
@@ -1466,6 +1468,9 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// IndeeHub public metadata is loopback-fetched by the native
// handler; the app port itself is never exposed on FIPS.
| "/api/public-catalog/indeedhub"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without
@@ -1482,6 +1487,11 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|| path.starts_with("/blob/")
// DWN sync — /dwn/health is step 1 of every sync; same story.
|| path.starts_with("/dwn/")
// Free IndeeHub playback. Stream and key authorization is delegated
// to IndeeHub; encrypted public segments are safe to proxy verbatim.
|| path.starts_with("/api/public-indeehub-stream/")
|| path.starts_with("/api/public-indeehub-media/")
|| path.starts_with("/api/public-indeehub-key/")
}
/// The ordinary API listener is a management surface even when contacted
@@ -2417,12 +2427,20 @@ mod merge_tests {
// 100% Tor by construction.
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
assert!(is_peer_allowed_path("/api/public-indeehub-stream/1234",));
assert!(is_peer_allowed_path(
"/api/public-indeehub-media/1234/projects/film/transcoded/seg_000.ts",
));
assert!(is_peer_allowed_path("/api/public-indeehub-key/1234"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/api/public-indeehub-stream"));
assert!(!is_peer_allowed_path("/api/public-indeehub-media"));
assert!(!is_peer_allowed_path("/api/public-indeehub-key"));
}
#[test]
+1 -4
View File
@@ -145,10 +145,7 @@ pub(crate) fn reserve_until(
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
()
}
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
Err(error) => return Err(error),
}
}
+40 -34
View File
@@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
.context("isolated test command failed");
}
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
// Shell snippets passed as one argument must reach the child intact.
// systemd-run otherwise expands $VAR/${VAR} against the manager's
// environment before `sh -lc` can see them (and usually replaces them
// with empty strings).
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.status()
.await
.context("sudo systemd-run spawn failed")
#[cfg(not(test))]
{
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
// Shell snippets passed as one argument must reach the child intact.
// systemd-run otherwise expands $VAR/${VAR} against the manager's
// environment before `sh -lc` can see them (and usually replaces them
// with empty strings).
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.status()
.await
.context("sudo systemd-run spawn failed")
}
}
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
@@ -1535,21 +1538,24 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
.context("isolated test command failed");
}
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.output()
.await
.context("sudo systemd-run output spawn failed")
#[cfg(not(test))]
{
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.output()
.await
.context("sudo systemd-run output spawn failed")
}
}
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
+1 -1
View File
@@ -474,7 +474,7 @@ async fn ensure_token(
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
fn generate_wallet_id() -> String {
let mut rng = rand::thread_rng();
let mut rng = rand::rngs::OsRng;
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
let num = rand::Rng::gen_range(&mut rng, 1..=999);
+20 -8
View File
@@ -16,7 +16,7 @@ use super::nut13::RecoverySource;
use anyhow::{Context, Result};
use bitcoin::secp256k1;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use tracing::debug;
/// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10;
@@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap {
}
impl PreparedSwap {
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id }
pub(super) fn input_fee_sats(&self) -> Result<u64> {
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?;
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?;
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
}
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str {
&self.keyset.id
}
pub(super) fn input_fee_sats(&self) -> Result<u64> {
let inputs = self
.inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input sum overflow")?;
let outputs = self
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output sum overflow")?;
inputs
.checked_sub(outputs)
.context("Prepared outputs exceed input value")
}
pub(super) fn inputs(&self) -> &[Proof] {
&self.inputs
+1 -1
View File
@@ -11,8 +11,8 @@ pub mod mint_client;
pub(crate) mod mutation;
pub mod nut13;
pub mod profits;
mod send_journal;
mod receive_journal;
mod send_journal;
pub(crate) mod purchase_fee_plan;
+1 -1
View File
@@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
#[tokio::test]
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
use crate::content_purchase_caller::{purchase, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::AtomicBool;
let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await;
+7 -1
View File
@@ -1795,8 +1795,10 @@ app:
}
}
exempt.sort();
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
// Reviewed 2026-10-09: lightning-stack's three retired endpoints
// disappeared; Cuprate restricted RPC moved from none to gate-open.
// DATUM's Stratum port is a raw public mining protocol; its separate
// administration interface remains gated and loopback-bound.
// Compare exact endpoints, not just a count that can hide substitutions.
let expected = [
("bitcoin-core", 8333),
@@ -1804,6 +1806,7 @@ app:
("core-lightning", 9736),
("core-lightning", 9835),
("cuprate", 18183),
("datum", 23334),
("electrumx", 50001),
("fedimint", 8173),
("fedimint", 8174),
@@ -1870,6 +1873,8 @@ app:
// Angor's indexer exposes public chain data/transaction broadcast;
// its optional standalone relay accepts signed public Nostr events.
// Neither mounts credentials or the node's internal relay database.
// Gashboard performs its own NIP-98/access-list authentication on
// every data route before issuing or accepting a session.
assert_eq!(
open,
vec![
@@ -1877,6 +1882,7 @@ app:
("angor-relay".to_string(), 8091u16),
("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16),
("gashboard".to_string(), 1337u16),
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16),
+4 -1
View File
@@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String {
}
fn manifest_apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// Keep source-tree discovery independent of the caller's working
// directory. Isolated test runners deliberately start in `core/`, while
// production uses one of the installed paths below.
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps")];
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
+4
View File
@@ -38,6 +38,10 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
}
}
// Rust 1.99 treats the boxed Future generated by async-trait as must-use and
// also sees the macro's own must-use marker. Keep the compatibility allowance
// scoped to this generated trait surface; callers still cannot ignore Result.
#[allow(clippy::double_must_use)]
#[async_trait]
pub trait ContainerRuntime: Send + Sync {
/// CLI used for offline app provisioning in this runtime's storage scope.
+93
View File
@@ -0,0 +1,93 @@
# Interactive hardware illustrations
The radio detection modal and unconfigured OpenWrt gateway use original
procedural Three.js models. The renderer follows the studio lighting,
rounded geometry, material finishes and instancing approach used for the
computer illustrations in `archipelago-website-deck`.
## Coverage
`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
including the generic fallback and three optional enclosure variants, plus
the Beryl AX (GL-MT3000) and a black Pixel 9 Pro. All existing `meshDeviceImages.ts` detection results
resolve to a procedural model key. USB chip identification remains a
best-effort visual match; it does not select firmware or claim exact hardware.
The radios include modeled rear faces, enclosure panels or populated boards,
and appropriate battery holders, connectors and antennas. These are stylized
recognition illustrations, not CAD models. Fine component placement and rear
details are representative; not every board revision or enclosure has been
verified against a rear photograph. The Beryl AX is the example router from
`docs/openwrt-gateway-setup.md`, not an assertion about the connected router.
Reference material:
- Beryl AX official front/rear/side hardware drawing:
https://static.gl-inet.com/docs/router/en/4/user_guide/gl-mt3000/hardware_info/mt3000_interface.jpg
- Beryl AX guide:
https://docs.gl-inet.com/router/en/4/user_guide/gl-mt3000/
- T-Echo manufacturer board and enclosure reference:
https://github.com/Xinyuan-LilyGO/T-Echo
https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/image/T-ECHO.jpg
- T-Deck manufacturer documentation:
https://wiki.lilygo.cc/products/t-deck-series/t-deck/
Manufacturer photographs are references only; none are copied into the app.
Geometry, grain and device labels are generated locally without CDN assets.
The black Pixel 9 Pro illustration uses an unmodified, locally bundled GrapheneOS
boot logo mask from its framework source. Its rounded outline, glass panels,
camera island, layered lenses, side buttons and ports are procedural geometry.
The display shows a static phase of the OS boot animation, distinct from Google's
bootloader notice. Source and attribution: `neode-ui/src/graphics/assets/ATTRIBUTION.md`.
## Interaction and lifecycle
`DeviceModel.vue` loads the renderer on intersection. It turns once per minute,
capped at 30 rendered frames per second. Drag horizontally to rotate; a mouse
can also tilt vertically. Touch preserves vertical page scrolling. Arrow keys
rotate/tilt, Home shows the front and End shows the back. Keyboard interaction
pauses automatic turning. The Pause/Rotate button controls autoplay.
Reduced motion disables autoplay and hides its control while preserving manual
rotation. Hidden tabs and offscreen models stop scheduling frames. Model changes
reuse the renderer/environment and dispose the previous geometry, textures and
materials. Unmount releases the renderer and context. Unsupported browser APIs,
unavailable WebGL, import failures or lost contexts leave a static fallback.
The router figure uses the remaining viewport height below the connection form
on desktop (minimum 260px); mobile layouts get a fixed 290px illustration. Small desktop
windows can scroll normally rather than collapsing the model to zero height.
## Hardware discovery
The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
Buy router / Buy phone replaces the model with a horizontal product carousel,
preserving the outer container's height. Discovery queries enabled relays from
`nostr.list-relays` for NIP-99 kind-30402 events. Phone discovery uses the `pixel`
keyword. Development builds show clearly
labeled sample listings when no matching live listings are found. Checkout and
the product View/Buy actions remain unimplemented.
Browser scenarios and reports stay outside this repository, under
`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
## Validation (2026-10-09)
- Production typecheck/build passes; the lazy renderer chunk is 136 KB gzipped.
- Existing gateway/cache tests (10) and radio setup tests (2) pass.
- Chromium 153, Firefox 155 and WebKit 26.6 pass at desktop 1440×900 and
phone 390×844: all 26 models, front/back rotation, drag, autoplay/pause,
reduced motion, zero offscreen/idle draws, model replacement, unmount/remount
and context-loss fallback. Reports: `/tmp/archy-models-final/`.
- Actual gateway and detection-modal components pass the same six-case matrix
with mocked RPC data. Reports: `/tmp/archy-device-pages-matrix/`.
- Delayed renderer loading, storage denial, reload and unavailable WebGL pass
the same matrix. Reports: `/tmp/archy-device-fallbacks/`.
- These are Linux browser tests with phone viewport emulation, not physical
iPhone/Android GPU or touch-scroll certification. No live router was changed.
Branch: `feat/3d-mesh-router`, based on ngit main
`5812f53c7c05027848a1007f818e9d33117b62e0`. The ngit helper could read relay
state but failed Git server authentication; main's published kind-30618 ref
was checked on relay.ngit.dev and that exact commit fetched from the existing
Git mirror. No remote proposal, merge or deployment was performed.
+273
View File
@@ -42,6 +42,254 @@ acceptance; this is a new paid-file incident.
## Current tasks
- 2026-10-10 three-node IndeeHub/terminal UAT checkpoint: Yaya, Framework and
Shorty serve byte-identical IndeeHub entry pages
(`0a2ef3393aacd96d834ad4666d4aa4c64c5fe0cc28f973351cfbc73a78156e7a`),
dashboard entry pages
(`b41a8d3c87ba923c9a4e5f7d0a61dafac593a3438510482d9e6b85a53bb63c9f`)
and management binaries
(`daf39c71b7029c0b51f8b74cc146fb3dd9af287744856e0e6a63bb63b40829ab`).
All manager health endpoints report `ok` with crash recovery complete.
Bitcoin/LND container IDs remained exact across the manager restarts. The
canonical runtime Nginx source is identical on all three nodes at
`524fd418ce71c6399f2772f7a105a968254e9a6414c96e45376422c414c18658`;
this matters because manager bootstrap otherwise restored an older runtime
template and removed terminal/playback routes on restart.
Terminal creation and authenticated listing pass on Yaya and Framework.
The live failure was missing `tmux`, after routing and origin failures had
separately been corrected. `tmux` is installed on all three UAT nodes and is
now required by both ISO installation paths. Shorty's proxy/runtime is
installed and Nginx-valid, but authenticated creation was not accepted because
its dashboard password differs; keep that as unverified rather than a pass.
Test-created Yaya/Framework session records were moved recoverably under the
support directory after acceptance and no longer appear in the session list.
The UI now includes actual response details for create failures instead of the
generic message, and its empty state occupies and centers within the full
terminal viewport. That dashboard build is byte-identical on all three nodes.
Cross-node public film discovery now works through FIPS in both directions:
Yaya receives Framework's *Arch x Indie* and Framework receives Yaya's
*Web5 Explained*. Framework's explicitly free film returned a JSON stream,
HLS manifest, 16-byte AES key and 1,736,192-byte MPEG-TS segment through the
Yaya same-origin bridge. A negative test found Yaya's project row said zero
while its signed offer required 15 sats; the first bridge build would have
treated that mismatch as free. The deployed guard now requires both project
data and signed terms to be explicitly zero. Framework receives HTTP 402
`rental required` and no stream URL for that title. Never weaken this guard to
make a paid playback test pass. Paid Cashu/Lightning rental, metered ecash,
entitlement recovery without another payment, and the intended native-identity
replacement for the old `0494` offer remain open end-to-end acceptance gates.
MeshCore V3 parsing now reads the channel after SNR/reserved bytes. The UI
recovers already-persisted impossible MeshCore channel IDs into Public while
preserving valid channels and other transports. Connected Nodes uses `Map`,
with full-width My connections/Find Nodes controls on mobile. Focused frontend
tests (8), TypeScript, production build, terminal packaging regression and
formatting pass. Three targeted backend regressions pass through the isolated
runner: guarded public streaming, MeshCore V3 channel parsing, and the peer
content path filter. The reviewed source was submitted as ngit proposal
`e970261bdf1578094cc05180853b850fafdf0950bb3f250d374672144d576bfc`
and merged once as `23c88da4c426fa8094e2e7c931586d27d028f4eb`.
Exact main mirroring and a complete mirror audit remain required before
OTA/ISO. No OTA or ISO was published by this UAT deployment.
- 2026-10-09 dual-node UAT correction: Archy `main` at `5b0b35401` was already
serving identical backend and dashboard artifacts on Yaya and Framework;
browser cache hid the new device-model screens. Yaya's terminal failed
independently because `/api/terminal/sessions` fell through to HTML. Its
HTTP and HTTPS proxy now return backend JSON `401` when unsigned; Framework
already did. Source correction ngit proposal `dd22151e` was reviewed and
merged as `b50fc5627ca4b108b85c40f72366a07a8cbcd5a9`, then pushed to
ngit and Gitea with local main-ref parity passing. Authenticated browser
terminal acceptance remains open; the UAT Nginx edit is live on Yaya.
IndeeHub frontend UAT on both nodes now includes the explicit local `/relay`
Archipelago source and publication panel flags after an omitted build setting
hid them. This is still a mounted UAT frontend, not a pinned app image. Yaya's
local relay has one protocol-qualified offer signed by the unwanted `0494`
identity; Framework's has none. Framework still runs its April `1.0.0` API
without the Archipelago module or registration pin, while Yaya has an October
API with three Archipelago migrations. Ordinary films exist on both nodes but
are not automatically signed Archipelago catalog offers. Preserve those films
and native identities; do not republish or delete the old signed event without
producer authorization. Backend image parity, node-specific pin preparation,
actual producer publication, cross-node discovery and paid playback remain
separate open acceptance gates.
- 2026-10-09 later dual-node UAT checkpoint: both nodes now run the same
source-built IndeeHub API image `f007b818d8ad` from `518de96`, with their
own existing native registration bindings and no runtime code mounts. The
three missing Framework migrations were applied after a validated database
backup; Yaya's database and prior overrides were also backed up, and the
overrides retired recoverably. Both nodes run the same rebuilt frontend and
asynchronous transcode worker image. Local public catalogs now return Yaya's
completed *Web5 Explained* and Framework's completed *Arch x Indie*;
Framework's stalled transcode was requeued once and completed. Posters and
encrypted HLS manifests return 200 on both nodes; raw source files return
403, unauthenticated key requests return 401, and MinIO policy reconciliation
succeeds twice on both nodes. Focused API/worker tests and production builds
passed, but authenticated purchase/playback and a new post-fix long transcode
remain unverified. Framework and Yaya still have separate local project
catalogs; neither local publication proves cross-node discovery. The only
signed Archipelago offer found was Yaya's earlier event from the unwanted
`0494` identity; do not sign or republish it as a different producer without
the operator's explicit identity choice. The IndeeHub source branch is not
merged upstream because its configured remote is currently inaccessible.
The Archy terminal proxy now returns JSON 401 on both nodes instead of HTML
for an unauthenticated API request; authenticated browser terminal acceptance
is still open. No catalog, OTA or ISO was published.
- 2026-10-09 recurrence: the operator again receives the profile-identity refusal.
Live reproduction shows the signed session still matches the selected native
identity, but the API's original DTO is back and omits `nostrPubkey`. Do not
weaken the frontend identity check. The earlier direct `podman restart` was
incorrect for this systemd-managed app: the attached service exits, then its
`Restart=always` cleanup/recreation returns to the original image. On this
repeat, the API disappears briefly and systemd recreates it at 16:30:53 UTC;
journal evidence and changed container ID confirm that lifecycle. The brief
earlier successful login was therefore insufficient deployment acceptance.
A per-node UAT correction now uses Quadlet drop-in
`~/.config/containers/systemd/indeedhub-api.container.d/90-profile-uat.conf`
to mount `/var/lib/archipelago/uat-overrides/indeehub-profile-08586e3/user.dto.js`
read-only at `/app/dist/users/dto/response/user.dto.js`. Artifact SHA256:
`4450f45def6c68396d9f4fa597a24ca74cec447d1cb3c8772af29e406d929e6d`.
The original image, source Quadlet, credentials, databases and identities are
unchanged. Restart via `systemctl --user restart indeedhub-api`, never direct
Podman. The module checksum survives two managed recreations. Real native
login after the first passes. The immediate second-start login obtained no
session; after explicitly verifying API health 200, the repeated real native
login passes with matching node selection, JWT subject and profile public key.
The service remains active/running with zero automatic restarts. This is a persistent **local UAT
override**, not a new signed image/catalog. Native upgrades remain held until
the reviewed image includes the fix and this exact override is retired; do not
bypass external-override/installation ownership checks. Recovery is to move
this specific drop-in into the existing private repair backup, daemon-reload
and restart the managed API; that restores the old image's known login defect.
No node identity or personal media was replaced. The previously pending
isolated registration regression has now passed: one test, zero failures.
- 2026-10-09 live repair checkpoint (supersedes the pending deployment notes
immediately below): Yaya now runs management backend SHA256
`938a90c7d9ecbdb179b1cf2886b5ed03e4d78c0d454f2cec42c0d9de3d447eb9`,
built from the registration correction in `424070d2`. Optimized build passed.
Management health, unchanged running container IDs, unchanged node identity key
and unchanged registration pin/marker checks passed. The old binary is retained
under `/var/lib/archipelago/support/indee-registration-424070d2/`.
The first context probe during inventory startup still failed; after inventory
recovery the authenticated `media.registration.context` probe succeeds and
returns all installation bindings. No catalog or app-data mutation was used.
The probe initially treated JSON-RPC `error: null` as failure; corrected it and
repeated the successful live request. Isolated backend regression compilation
remains pending after severe build-host swapping; do not claim its test passed.
A real native Home Serve login then exposed another defect: `UserDTO` omitted
`nostrPubkey`, so the strict frontend profile check correctly refused the
otherwise matching signed session. IndeeHub `08586e3` adds this public field;
two DTO regressions and backend production build pass. Deployed only the built
DTO module with its previous copy saved under the private
`indee-profile-08586e3` artifact directory, then restarted only `indeedhub-api`.
API health passes. A clean real browser selected native **Home Serve**, approved
only its kind-27235 authentication request, and verified that the native choice,
session subject and API profile key match. The signer closes normally. No
identity was created/replaced and no payment or publication was submitted.
Frontend `7b18912` and API DTO remain running-container patches; durable image/
managed deployment, complete video upload/publication and cached-account-switch
acceptance remain open. Preserve the operator's native identities throughout.
- 2026-10-09 UI checkpoint: IndeeHub commit `40b3798` is served on Yaya
(index SHA256 `d921a88448f89674809dbe623ec045849012b1fb0330e3abf1d2d6836e9b3c96`).
Publishing now appears only in its own editor tab; Assets remains mounted
across tab switches. Copy distinguishes computer upload from Cloud-backed
catalog publication; saved-registration recovery is under a details control.
Production build and 26 focused frontend tests pass. Browser checks verify
native signer availability, no automatic login, removal of local account
controls and clearing of legacy app-local accounts. Real selected-identity
sign-in and video upload/publication remain unverified. This is a running
container frontend patch, not a durable pinned-image/catalog deployment.
Backend correction `424070d2` has ngit proposal
`9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`;
isolated regression and optimized build are still running, not passed or
deployed. Preserve this distinction when resuming the backend work.
- 2026-10-09 follow-up: operator reports missing autosign and the same generic
registration error. `40b3798` disabled provider auto-authentication without
replacing it with app authentication on native selection; this was a regression.
IndeeHub `7b18912` adds a single shared automatic/manual login path driven by
the trusted provider's `onIdentitySelected`. It rejects mismatching signer keys
and changed selections, checks the backend profile, and never falls back to a
cached app identity. Twenty-one focused tests and the production build pass.
Yaya serves index SHA256
`1c7fee64430b0d993288408dbe493d0f4f55a49fe2422a3507c7204c12343632`;
the native provider bytes are unchanged. A disposable served-app browser test
verifies selection triggers exactly one kind-27235 signature request without
clicking login; it intentionally stops before signing with a stub. This is not
real-identity login acceptance. The first browser run raced deployment and
failed against the previous build; the post-deployment rerun passes.
Authenticated read-only registration RPC still reproduces the generic failure,
with server cause `IndeeHub is not installed`; backend build/test remain pending.
- [ ] **2026-10-09 IndeeHub UAT remains failed:** operator still reports wrong
Nostr identity, upload failure and confusing always-visible publishing UI.
Preserve native identities; remove only app-local generated/imported accounts.
Require explicit Archipelago selection and matching API profile. On Yaya,
`media.registration.context` logs `IndeeHub is not installed` while all seven
containers run. Authenticated state includes only the parent `indeedhub`
package; the registration code incorrectly requires a separate
`indeedhub-api` package record. Candidate correction retains parent running/
installed checks and the existing API installation pin; it checks a legacy
API package when present. Source tests/build and live registration acceptance
must complete before closing. Move publishing to its own editor tab, preserve
computer-upload state and existing pending registration requests.
Retained operator UAT follow-up (2026-10-09; none of these are closed by an
isolated build or a running-container patch):
- [ ] Keep only the selected native Archipelago signer identity; reject any
cached or mismatching IndeeHub identity, and verify Home Serve and
Archipelago sign-in with real selected identities after reload.
- [ ] Verify computer uploads for every asset field, byte-level progress,
project Save/Publish persistence, and reloaded poster/video/trailer URLs.
The poster URL currently omits the slash after `/storage` on Yaya.
- [ ] Restore the authenticated, mobile-friendly Cloud file modal, with an
actual attach action beside computer upload for every required asset
field. The current Cloud iframe is not an attachment workflow.
- [ ] Verify that any producer can register and publish Cloud video, including
exact-project/video/terms signing, resumable registration, and playback.
- [ ] Deliver and UAT both metered ecash playback and fixed-period rental,
retaining Lightning payment/rental support where promised. A price in
sats plus one-time Cashu rental is not metered ecash streaming.
- [ ] Deploy pinned, durable IndeeHub frontend/API images and test their
recreation without losing native identities, uploaded assets, projects,
payments, or publication state. Running-container file patches do not
satisfy this gate.
Dual-node UAT checkpoint, 2026-10-09: Yaya and Framework serve the same
IndeeHub frontend build through restart-surviving UAT Quadlet overrides;
their existing native-provider bytes were preserved. Both app endpoints
return HTTP 200 and protected API endpoints reject unauthenticated requests.
Narrow API overrides add the profile field required by the strict signer
check and correct `/storage/projects/...` poster URLs. Yaya's saved project
still has a poster and video key; its poster now returns HTTP 200 while the
private video returns HTTP 403 without authorization. A poster-only anonymous
`GetObject` rule was added to the public bucket; no private bucket read rule
was added. Framework's two missing IndeeHub buckets were created without
replacing other storage. Source backend tests/build, frontend build, and 12
focused identity/upload tests pass. Real selected-identity sign-in, upload
completion and Save/reload on both physical nodes still require operator UAT.
The Cloud browser is view-only and is **not** an attachment picker. These
overrides are not pinned managed images or release acceptance.
- [ ] **Yaya disk threshold notification recovery:** after safely reclaiming
only BuildKit cache, actual data-volume use is 62% (127,440,146,432 of
219,181,039,616 bytes on 2026-10-09). The previous 164 GiB / 194 GiB
display reflected real usage before cleanup, not merely stale UI.
Source monitoring pushes notifications only when current alerts are
nonempty, so a previously fired threshold notice may remain after usage
recovers. Fix notification clearing and verify live recovery after app
removal/cache cleanup without deleting wallets or persistent app data.
- [x] Yaya App Store component/alias regression (reported 2026-10-02): one
Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce
with its icon (merge legacy btcpay pins), one NetBird entry (hide server
@@ -1598,3 +1846,28 @@ isolated runner. The full isolated suite, production backend build, another
explicit native update, post-update runtime/data proofs, and paired browser
acceptance remain pending. No wallet, payment, personal-media or profile changes
are part of this readiness correction.
## 2026-10-09: two-node IndeeHub UAT — open acceptance items
Yaya and Framework now have matching UAT frontend bundles, but this is **not**
feature acceptance or release publication. The backstage `indeehub.com/watch/`
slug control has been removed from the active bundle. The local My List button
now calls the library API instead of only toggling its icon; signed-in browser
acceptance is pending. Cross-node saves remain open because the library API is
local-project-only. Cross-node comments, reactions and zaps remain open: social
events use node-origin-specific content IDs and each `/relay` requires that
node's login. Do not treat cross-node public film listing as social federation.
Framework logged `content.playback-prepare: Not a purchase route` after a Cashu
rental attempt. The buyer sent a rental-control path through a purchase-only
transport guard. A narrow authenticated rental-control route is implemented;
the release binary compiled, its route unit test passed through the isolated
runner, and the identical binary is deployed on both UAT nodes with healthy
RPC and session services. An existing-purchase retry without another payment
remains pending. The earlier
Framework LND startup fix does not close this paid-file recovery incident.
Lightning purchase/zap still lacks configured BTCPay credentials on both UAT
nodes; the API now reports service unavailable instead of an opaque Invalid URL,
but payment is not accepted. Framework's listed film has no verified media
registration or signed stream offer; listing is not proof of playable media.
No OTA, ISO or public release follows from these UAT changes.
+201
View File
@@ -1,7 +1,165 @@
# Archipelago 1.9.0-alpha release acceptance
## Three-node expired-rental renewal UAT — 10 October 2026
- ngit proposal `267f63692f16af34771b7268ffd71502f9a1b087661044730bb891478c82212d`
was merged once as `f6e84fdcb46eb2ac998f4ad53cd9c34ccfb03ac4`; the exact `main`
commit is on ngit and Gitea and the local/main mirror check passes.
- Explicit renewal is permitted only after the authenticated seller reports the
original immutable timed-rental window as expired. Active and incomplete
purchases continue to recover under their existing operation and are not paid
again. The isolated backend regression passed 1/1; the dashboard bridge passed
29/29 tests; the IndeeHub player passed 10/10 tests. A real expired Framework
rental was recovered before this change without another payment and confirmed
the missing transition; an actual renewed payment remains operator UAT, not a
source-test pass.
- Optimized backend SHA256 `97f4ffcc6e5f47eaeb88bfcd72d858aa1b15baa2ae775d02718ed7074151f4fd`,
dashboard index SHA256 `d6ffe81f2e7449b12ebb3affed63802520a75634d753ed81d77605d17c5e8e7c`,
and IndeeHub index SHA256 `ee9192ab758871df04ee519c69a3dcb8693001fcc69c2b547cc7ca94236eba54`
are deployed on Yaya, Framework and Shorty with private rollback copies.
All three management health probes pass. Bitcoin/LND and IndeeHub database,
Redis, MinIO, relay, API and ffmpeg container IDs remained unchanged.
- Framework's obsolete nginx `sub_filter` was injecting a duplicate native
signer provider into the self-contained frontend. It was removed with its
previous configuration retained privately; all three nodes now serve the same
IndeeHub HTML. The mobile Connected Nodes footer now gives Map and Refresh
equal width and uses Refresh text rather than an oversized mobile icon.
- This is UAT deployment evidence, not OTA/ISO acceptance. Lightning rental,
metered ecash, Shorty authenticated operator acceptance, the remaining active
regression checklist and full historical mirror parity remain release gates.
## Yaya alpha UAT continuation — 9 October 2026
Operator renewed deployment authorization for `yaya-server` for combined UAT,
including IndeeHub, JustWorks and the merged external-access, Gashboard and
DATUM work. OTA/ISO publication still requires subsequent operator acceptance.
Fresh read-only inspection found the prior operation `36ac4803` Restored with
cleanup complete, no lifecycle holds, and all 28 current containers running.
The API is already the shutdown-hook-aware version (main.js SHA256
`6ff3d4fa5d6a17c530a8e69b2b8b65ec8214c03e71f9a8cf3a27dd53702f1120`).
Its one project, one content item and one shareholder caused the legacy
empty-business gate to refuse. All 11 checked payment/rental/publication history
and intent tables are empty; pending shareholder revenue is zero, and BTCPay/
Strike credentials and endpoints are absent. These observations do not qualify
updates of nodes with monetary history or configured payment providers.
Correction `1e11c93e` adds a bounded money-free UAT path: exact known API entry
point, disabled registration/publication, absent providers and HTTP connections,
closed ingress, stopped frontend/worker, paused empty queue, zero monetary state,
and exact database commitments before and after the existing child SIGTERM path.
Changed data refuses forward cutover and retains live data for native recovery.
All 63 maintenance-controller tests passed. A matching optimized backend build
and live deployment/verification remain pending at this checkpoint.
The README server-install instructions and verified published 1.9.0-alpha ISO
link are commit `31ea755c`, including alpha/funds-at-own-risk and planned
pre-production hardening wording. Both commits were reviewed and merged through
ngit proposal `5bd850d3` (status applied), merge `ecc8cc80`. Main and the existing
`v1.9.0-alpha` tag passed local/ngit/Gitea parity. The ISO asset returned HTTP 200
and its published SHA-256 is
`aad5f0350428976969043079a63b0e7a8264dcee2574526bd34719c798c750af`.
This documentation update does not publish a new installer.
### IndeeHub sign-out UAT deployment — PASS with retained limitation
The dashboard/provider sign-out change was reviewed as ngit proposal
`8648b7362c3ddb2195a7f5466cab9c8ad224ca289996fec303f4a7f8091e7c42`
and merged at `2cb1bae5`. A first supervised Yaya attempt
`74ef081b-0e7a-49c0-8fd8-959ebcf4b2ec` correctly refused before target startup
because the maintenance controller recognized only coherently disabled
registration/publication flags, while this money-free UAT installation has both
flags enabled. Native recovery completed `Restored`, cleanup complete and
maintenance released; PostgreSQL, Redis, MinIO, relay and API identities were
preserved, and only the frontend/worker recovery containers were recreated. The
failed attempt and its private receipts remain retained; it was not retried or
reused.
The narrow controller correction accepts either coherently enabled or coherently
disabled UAT flags while continuing to reject missing, ambiguous or mixed flags
and preserving the exact API binary, zero monetary/business/outbox/entitlement/
revenue, absent-provider and absent-connection requirements. Sixty-three focused
controller tests passed. The required isolated backend suite passed 2,066 tests,
with five explicit opt-in ignores and zero failures. It was reviewed as ngit
proposal `ecac4574fdefcdb17b913b720d1f7b446db6cd9a6f6063f78a22d6b7af6d320c`,
merged at `5812f53c`, mirrored exactly to ngit and Gitea, and passed the local
main-ref parity check.
Yaya now runs optimized backend SHA256
`6a1389c8945f95183ea918b653f813e3125e4254ec3078e8daa1773d896573df`
with helper SHA256
`4e086c7b8ef985944f1a370d932d9107ec87d2164b004f130522d25a81184c23`.
The signed 69-app combined catalog remains SHA256
`88888f6a541e2b13b70e2ab57ae6c355420313d61fd47c152b4341b2cda3ab1a`.
After native recovery, a fresh seven-unit plan was bound to the actual recovered
frontend/worker recipes; its SHA256 is
`a6d26db482c86ff8dda6b2c2816473f2cdcbd5def98c9cf640b97e42d53206bd`.
Offline preparation preserved the exact seven originals without changing any
runtime. Dashboard index and provider SHA256 values are respectively
`f3cff3f8a27ae94318c34ef61638e22a73bb06fc076634ebe981309ee3c5008e`
and `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`.
Exactly one new authenticated `package.update` was submitted. Operation
`309f3d74-74d4-478a-a02e-cf077bab547d` completed Prepared → Editing → Starting →
**Committed**, cleanup complete; maintenance is **Released/committed**, and both
fresh database and four-volume restore proofs pass. Post-commit verification
passed all seven exact target units/images and healthy new runtimes, exact
database commitments, all 110 migration rows including the three reviewed
additions, 21 unchanged unrelated runtimes, 16 unchanged identity/operator
files, 18 unchanged unrelated units, cleared holds/fence, coherent enabled UAT
flags, nginx configuration, and matching host/container provider bytes.
A disposable live Chromium test injected valid-looking stale login hints without
using a real identity. The production API did not grant authentication; the
primary login credentials and active-account selection cleared, and the app
remained signed out after both normal and cache-bypassing hard reloads. Three
secondary API-client hints (`indeehub_api_token`, `indeehub_api_refresh`, and
`indeehub_api_expires`) remained in that synthetic profile but did not restore
authentication. Treat complete removal of those non-authorizing hints as retained
hardening rather than claiming every cache key was erased. The IndeeHub frontend
commits `0d6434e` and `71cf546a` remain local because that repository has no ngit
coordinate; do not publish them Gitea-only under the mirror policy.
### IndeeHub selected-identity regression — UAT reopened 9 October 2026
Operator UAT disproved the earlier sign-out acceptance: choosing the homeserver
identity could continue with a previously active saved account and display a
different npub. `loginWithExtension` caught selection/provider failures without
rethrowing, after which the modal read `accountManager.active`; that pointer could
still name the old account. IndeeHub commits `a4d3946` and `86256ee` now clear the
active pointer before an explicit native switch, propagate failure, bind the
authentication call to the exact returned signer account, and retain the
Archipelago provider hook in the source index. A regression starts with a stale
saved account and proves a failed selection cannot fall back to it while the
saved key itself remains preserved.
All 155 frontend tests, type checking and the production build pass. A live Yaya
frontend-only correction is installed with rollback under the operator's private
artifact directory. The served index references the provider, provider SHA256 is
still `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`,
the service worker was regenerated to invalidate the brief provider-less cache,
and `/health` passes. Automated Chromium confirms the provider loads and no stale
active account is restored. Real homeserver selection and displayed-npub matching
remain pending operator UAT; the current live filesystem correction is not yet a
new signed catalog/image activation and must not be described as durable managed
deployment acceptance.
## Combined Yaya UAT candidate — 9 October 2026
Follow-up operator request: retain all node-native identities; remove IndeeHub's
Create Account/private-key import and reset only its old browser accounts.
IndeeHub commit `7fbdc74` removes generation/import handlers and screens, performs
a one-time removal of `indeedhub-accounts`, `indeedhub-active-account` and app
session credentials, and corrects the default API base from browser localhost
to `/api`. The previous live hotfix was built without explicit API settings,
explaining its network error; its earlier success claims were premature.
Twenty-one focused authentication/reset tests pass and the production build
passes with `/api`, `/storage`, `/relay`, mock off and publication enabled.
This updated static bundle is deployed to Yaya's existing frontend container;
its index SHA256 is `a14834acab5fc9b42f55a92c540b9b765334ac7d2d2628ddc82bc1c5725d53ad`.
Node identity storage was not modified. Signed-image recreation and real native
identity sign-in acceptance remain outstanding.
The combined source is published on ngit and mirrored byte-for-byte to Gitea at
main `bb933d409187a8181e284a5a91a81b4e036b3036`, based on accepted main
`aff408cc`. Its integration commits retain each proposal head as a parent:
@@ -1197,3 +1355,46 @@ Public assets: [1.9.0-alpha release](https://source.archipelago-foundation.org/l
The separate follow-up branch now passes1,683 backend tests (four existing ignored)
and all1,222 frontend tests after correcting the storage classifier. These are
source test results, not inclusion in the published artifacts or live acceptance.
## IndeeHub native login and registration repair — 2026-10-09 follow-up
ngit proposal `9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`
was reviewed and merged once as
`83feb1806336a1204ea0d6232de5220d4f6656ad`. It retains installed/running
parent, existing node identity/pin, installed origins and legacy API component
checks while accepting managed stacks without a separate API package record.
See the latest checkpoint in `post-1.8.22-regressions-20261001.md` for exact
deployed hashes, preserved-state checks, backups and the failed earlier probes.
Yaya's authenticated registration-context RPC now passes on the optimized
candidate backend. Real native **Home Serve** authentication passes after
IndeeHub frontend `7b18912` and API profile correction `08586e3`: chosen node
key, signed session subject and API profile key match, with native consent and
normal signer dismissal. Frontend 21 focused tests, API two DTO tests, and both
production builds pass. The isolated Rust regression build is still running;
no unit-test pass is inferred from the live check.
The frontend/API module changes are running-container UAT patches, not a new
durable pinned app image. Full upload/publication and cached-identity-switch
acceptance remain open. No payment, public listing, identity replacement, fleet
catalog, OTA or ISO publication was performed by this repair.
## ngit UAT contribution disposition — 2026-10-09
The overlapping hardware proposals `a9165a5e`, `5a210008`, `a11eb1da`,
and `39c7c335` were reviewed as one contribution and merged once at
`a5637d9350aa05fa52136e736a64a5bccd1539c6`. ngit marked all four
applied after main publication; no duplicate Gitea merge was created.
The hardware frontend production build and six focused UI tests passed.
Terminal proposal `c1156d20` merged at `02671438d`; its dashboard-origin
security correction was separately reviewed as ngit proposal `2e6577db`
and merged at `bb7961277`. The terminal frontend build, two shell suites,
and isolated backend `terminal_origin_tests` passed. Declared-author proposal
`924f78e1` merged at `3667105b2`; its isolated backend regression passed.
The IndeeHub open-UAT checklist proposal `f9914a73` merged at `4b65879b6`.
After publishing main at `4b65879b6`, ngit showed no open/draft proposals.
`python3 scripts/check-git-mirrors.py --local` passed for local/ngit/Gitea
main at `4b65879b6`. This is a main-ref check only, not an `--all` historical
branch/tag audit, live-node acceptance, or permission to publish OTA/ISO.
+159
View File
@@ -0,0 +1,159 @@
# Release OTA and ISO from latest `main`
This is the operator handoff for producing a release from whatever commit is
the reviewed tip of `main` at the time. A release is not ready merely because
the artifacts build. Every active regression, live-node acceptance, mirror and
publication gate below must pass.
## 1. Freeze an exact reviewed source revision
Use a fresh clone or clean worktree. Do not build from an UAT node or a dirty
tree.
```bash
git switch main
git pull --ff-only ngit main
git status --short
git rev-parse HEAD
python3 scripts/check-git-mirrors.py --local --all
```
`git status --short` must be empty. The full mirror audit must show matching
advertised branches/tags on ngit and Gitea. Inventory and resolve any unrelated
drift; never force-push, delete refs, or rewrite published history merely to
make the check green. Record the accepted ngit proposal and merge commit in the
release acceptance ledger.
Read and retain every unchecked item in:
- `docs/post-1.8.22-regressions-20261001.md`
- the current release acceptance ledger and UAT checklist
Run the source gates and the documented live-node matrix. On a node with
installed apps, backend tests must run only through the isolation wrapper:
```bash
scripts/test-backend-isolated.sh
tests/release/run.sh
python3 scripts/check-app-catalog-drift.py --release --strict
```
Keep source/unit results separate from actual-node acceptance. In particular,
prove that paid-file recovery never makes a second payment and that app cleanup
preserves wallets, persistent data, and uninstall decisions.
## 2. Prepare and sign the OTA release
Choose a new SemVer that has never been published. Do not move or replace an
existing release tag. Curate the new top entry in `CHANGELOG.md`, then preview:
```bash
release_version=X.Y.Z-alpha
scripts/create-release.sh "$release_version" --dry-run
```
When all gates and review are complete, run the real preparation from clean
`main` in an interactive terminal:
```bash
scripts/create-release.sh "$release_version"
```
The script builds the backend, frontend, AIUI and radio tools, creates a signed
pending manifest and staged OTA artifacts, commits the release preparation, and
creates annotated tag `v$release_version`. During the signing prompt, paste the
24-word release master mnemonic **once**, press Enter, then Ctrl-D on the next
line. Never put the mnemonic in a command, file, chat, log, or Git. Do not use
`RELEASE_MASTER_MNEMONIC` for the normal operator ceremony.
If an already-prepared pending manifest needs signing on the offline/operator
terminal, first ensure the release binary was built from the exact frozen
commit, then run:
```bash
bash scripts/sign-manifest.sh \
"releases/pending/v${release_version}/manifest.json"
```
The script cryptographically verifies the result against the public release
root pinned in the binary. A failed verification is a hard stop.
Review the preparation commit, tag target, staged artifacts and signature. Push
the exact reviewed commit/tag to both publication mirrors according to the
ngit-first workflow; do not create independent merges on each platform.
## 3. Build and sign the installer ISO
The ISO builder requires clean `main`, matching versions, the annotated tag and
the signed live manifest. After OTA publication has safely promoted the live
manifest (next section), return to the exact tagged clean tree and run:
```bash
scripts/build-iso-release.sh
```
For a normal release, do **not** pass `--skip-gates` or `--no-qemu`. The command
runs the release harness, strict catalog check, artifact checks, ISO build,
mount-level smoke test and headless QEMU boot. Keep its final PASS summary as
release evidence.
Sign the generated ISO checksum document in the operator ceremony:
```bash
iso_path=/absolute/path/to/archipelago-${release_version}.iso
bash scripts/sign-iso-checksums.sh "$iso_path"
core/target/release/archipelago ceremony verify \
"$iso_path.sha256.json"
sha256sum -c "$iso_path.sha256"
```
The signing script again expects one mnemonic paste, Enter, then Ctrl-D. It
does not place the mnemonic on disk.
## 4. Publish without exposing incomplete updates
Configure the Gitea remote with a public HTTPS URL and keep its credentials in
Git's credential helper, never in the remote URL. Then run:
```bash
scripts/publish-release-assets.sh "$release_version" gitea-vps2
```
This script deliberately pushes the tag first, creates the release, uploads and
byte-verifies the OTA assets, and pushes the manifest-bearing `main` last. When
the signed ISO files exist, the same command attaches and verifies them. Never
manually publish `releases/manifest.json` before its referenced assets are
downloadable and verified.
After publishing, mirror the exact accepted `main` commit and annotated tag to
ngit and Gitea, then verify both:
```bash
python3 scripts/check-git-mirrors.py --local \
--ref "refs/tags/v${release_version}"
python3 scripts/check-git-mirrors.py --local --all
scripts/check-release-assets.sh releases/manifest.json
```
A failed push, missing ref, tag-object mismatch, unavailable mirror, incomplete
asset, or mismatched byte hash blocks publication. A main-only check is not full
historical mirror parity.
## 5. Canary and final acceptance
Before announcing the release:
1. Apply OTA to one non-critical canary from the signed manifest.
2. Reboot it and verify management health, signer/native identity, installed and
deliberately removed apps, wallet state, persistent app data, terminal,
networking/FIPS, and the active regression checklist.
3. Exercise rollback/recovery without changing wallets or app data.
4. Boot the ISO in QEMU and on representative physical hardware; perform a clean
install and an upgrade-path check, then repeat the same acceptance matrix.
5. Verify public release downloads and both Git mirrors once more. Record exact
commit, annotated tag object, artifact hashes, signer verification, node
evidence, ngit proposal/merge, known deferrals and operator acceptance in the
release ledger.
Do not call the release complete if any required live acceptance, signature,
mirror, asset, OTA reboot/rollback, or ISO boot/install result is missing.
@@ -386,6 +386,7 @@ DOCKERFILE_HEAD
python3 \
curl \
git \
tmux \
vim-tiny \
nano \
ca-certificates \
@@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \
curl \
wget \
htop \
tmux \
vim-tiny \
nano \
ca-certificates \
+102
View File
@@ -37,6 +37,21 @@ map "$archy_management_private_source:$archy_management_private_client:$archy_ma
}
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
# Only IndeeHub on this same node may read the owner's FileBrowser API for
# its in-app Cloud picker. Never reflect arbitrary cross-port origins.
map "$host|$http_origin" $archy_cloud_picker_origin {
default "";
"~^([^|]+)\|http://\1:7778$" $http_origin;
}
map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin {
default $http_origin;
~^GET\|same-origin\| "$scheme://$http_host";
# Older WebViews can omit both Origin and Fetch Metadata on same-origin
# GETs. Only synthesize in that header-free case; a cross-origin request
# carrying Origin still reaches the backend unchanged and is rejected.
"GET||" "$scheme://$http_host";
}
# Rate limit zones
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
@@ -279,6 +294,30 @@ server {
}
# Serve static files (Vue.js SPA)
# The developer terminal is an authenticated JSON API. Keep it ahead of
# the SPA fallback so a missing session returns JSON 401, not index.html.
location ^~ /api/terminal/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
# Browser-facing bridge for IndeeHub streams hosted by connected nodes.
# The backend enforces the owner session on peer routes and free-content
# policy on public routes; keep these ahead of the dashboard SPA fallback.
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_buffering off;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
@@ -315,6 +354,12 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Browser-mediated Cloud selection uses the existing node session.
# Only this node's own IndeeHub origin is admitted by the map above.
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
# Limit request body to 1MB for RPC calls
client_max_body_size 1m;
@@ -379,6 +424,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
@@ -603,6 +666,8 @@ server {
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_request_buffering off;
proxy_set_header Accept-Encoding "";
@@ -1257,6 +1322,25 @@ server {
return 404;
}
location ^~ /api/terminal/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_buffering off;
}
location / {
try_files $uri $uri/ /index.html;
}
@@ -1306,6 +1390,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
+2
View File
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
## Vendored
- `src/graphics/assets/grapheneos-boot-mask.png` — GrapheneOS boot artwork,
Apache-2.0; see the adjacent ATTRIBUTION.md for the pinned source and credits.
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
+6
View File
@@ -4470,6 +4470,12 @@ app.post('/rpc/v1', (req, res) => {
// ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ──
case 'openwrt.get-status': {
// Keep the local preview in the unconnected state so the router model
// is immediately visible. Set MOCK_OPENWRT_CONNECTED=1 to preview the
// connected dashboard instead.
if (!process.env.MOCK_OPENWRT_CONNECTED) {
return res.json({ error: { code: -32004, message: 'No router configured' } })
}
return res.json({
result: {
host: '192.168.8.1',
+948 -1034
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.9.0-alpha",
"version": "1.9.1-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -41,6 +41,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"three": "^0.185.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -51,16 +52,17 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^5.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22",
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"dockerode": "^5.0.1",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",
@@ -68,7 +70,7 @@
"typescript": "~5.9.3",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.1.1",
"vitest": "^5.0.3",
"vue-tsc": "^3.1.3",
"ws": "^8.18.0"
}
@@ -1,13 +0,0 @@
# Device Artwork Attribution
The device illustrations in this directory are from the
**Meshtastic® project** — https://meshtastic.org
(https://github.com/meshtastic), © Meshtastic contributors,
licensed under GPL-3.0.
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
identify compatible hardware. No endorsement by the Meshtastic project is
implied.
Any modifications to these images made for Archipelago are likewise available
under GPL-3.0 as part of this public repository.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 96 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" fill="#353535" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" fill="#1e1e1d" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" fill="#1e1e1d" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" stroke-width="2.41px" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" stroke-width="2.41px" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" fill="#30c2db" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" fill="#353535" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" stroke-width="3.91px" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" fill="#dcf0f2" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" fill="#dcf0f2" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" stroke-width="2.41px" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" stroke-width="1.78px" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" stroke-width="1.81px" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" stroke-width="1.81px" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" fill="#dfeaf7" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" fill="#17907f" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" fill="#2b2b2b" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" fill="#dfeaf7" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" stroke-width="2.25px" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" stroke-width="4px" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" stroke-width="2.25px" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" stroke-width="2.25px" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" fill="#050606" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" fill="#050606" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" stroke-width="2.25px" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 137 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

@@ -1,502 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 293.56501 470.885"
version="1.1"
id="svg207"
sodipodi:docname="buyer benmeshtastic 2-03.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="293.565"
height="470.88501"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview207"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.96807266"
inkscape:cx="186.96944"
inkscape:cy="370.32344"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_2" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:#474746;}.cls-2{fill:#3a3a3a;}.cls-3{fill:#202121;}.cls-4{fill:#dbdcdd;}.cls-5{fill:#cbcbcc;}.cls-11,.cls-16,.cls-18,.cls-6{fill:none;}.cls-11,.cls-16,.cls-6{stroke:#1d1d1b;stroke-linecap:round;stroke-linejoin:round;}.cls-6{stroke-width:0.95px;}.cls-7{fill:#e5e2e5;}.cls-8{fill:#444e85;}.cls-21,.cls-22,.cls-9{fill:#1d1d1b;}.cls-10{fill:#d1b066;}.cls-11{stroke-width:1.3px;}.cls-12{fill:#d8b258;}.cls-13{fill:#b28e45;}.cls-14{fill:#a6a9af;}.cls-15{fill:#d1c854;}.cls-16{stroke-width:1.01px;}.cls-17{fill:#fff;}.cls-18{stroke:#fff;stroke-miterlimit:10;}.cls-19{fill:#ba9c61;}.cls-20{fill:#715c34;}.cls-21{font-size:13.29px;}.cls-21,.cls-22{font-family:LucidaConsole, Lucida Console;}.cls-22{font-size:8.44px;}</style>
</defs>
<g
id="Layer_2"
data-name="Layer 2"
transform="translate(-106.20269,-157.045)">
<path
class="cls-1" fill="#474746"
d="m 396.14,293.93 v 311.36 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 v -225.7 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.6 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,21.94 z"
id="path1" />
<path
class="cls-2" fill="#3a3a3a"
d="m 358.92,384 a 23.67,23.67 0 0 1 23.64,23.64 v 176.6 a 23.67,23.67 0 0 1 -23.64,23.64 H 147.31 A 23.67,23.67 0 0 1 123.67,584.24 V 407.61 A 23.67,23.67 0 0 1 147.31,384 h 211.61 m 0,-5 H 147.31 a 28.64,28.64 0 0 0 -28.64,28.64 v 176.6 a 28.65,28.65 0 0 0 28.64,28.64 h 211.61 a 28.64,28.64 0 0 0 28.64,-28.64 V 407.61 A 28.64,28.64 0 0 0 358.92,379 Z"
id="path2" />
<rect
class="cls-2" fill="#3a3a3a"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect2" />
<rect
class="cls-3" fill="#202121"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect3" />
<path
class="cls-2" fill="#3a3a3a"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path3" />
<path
class="cls-1" fill="#474746"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61 L 240.25,272"
id="path4" />
<path
class="cls-4" fill="#dbdcdd"
d="m 353.11,172.61 a 54.23,54.23 0 0 0 -32.38,-10.67 h -49 a 54.49,54.49 0 0 0 -54.49,54.48 V 282 h -5.21 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.24,54.24 0 0 1 37.59,15.09 z"
id="path5" />
<path
class="cls-5" fill="#cbcbcc"
d="m 370,212 v 51.84 h -21.49 l -9.24,-46.17 V 209 A 19.5,19.5 0 0 0 319.79,189.52 H 260.87 A 19.48,19.48 0 0 0 241.39,209 v 60.74 c -1.23,0.56 -0.86,2.13 -0.86,2.13 l -6.91,1.75 c -5.66,1.43 -7.63,4.78 -9.44,7 -1.81,2.22 -2,1.41 -2,1.41 h -4.89 v -65.61 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.23,54.23 0 0 1 32.38,10.67 v 0 A 54.3,54.3 0 0 1 370,212 Z"
id="path6" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path7" />
<path
class="cls-2" fill="#3a3a3a"
d="M 329.33,586.79 H 198 A 24.51,24.51 0 0 1 173.52,562.31 V 429 A 24.5,24.5 0 0 1 198,404.51 H 329.33 A 24.49,24.49 0 0 1 353.8,429 v 133.31 a 24.5,24.5 0 0 1 -24.47,24.48 z m -128.81,-27 H 326.8 V 431.51 H 200.52 Z"
id="path8" />
<rect
class="cls-7" fill="#e5e2e5"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect8" />
<path
class="cls-3" fill="#202121"
d="m 166.62,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path9" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path10" />
<path
class="cls-3" fill="#202121"
d="m 359.9,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path11" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path12" />
<path
class="cls-3" fill="#202121"
d="m 359.9,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path13" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path14" />
<path
class="cls-3" fill="#202121"
d="m 166.62,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.86,12.86 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path15" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path16" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path17" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path18" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path19" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path20" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path21" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path22" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path23" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path24" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path25" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path26" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path27" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path28" />
<path
class="cls-9" fill="#1d1d1b"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path29" />
<path
class="cls-9" fill="#1d1d1b"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path30" />
<path
class="cls-1" fill="#474746"
d="m 384.27,274.39 v -3.8 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31 c 0,0 74.61,0 76.35,0 0.32,0 6.89,-0.2 10.16,2.49 z"
id="path67" />
<path
class="cls-15" fill="#d1c854"
d="m 223.32,282 a 21.84,21.84 0 0 0 -3.5,11.91 v 41.6 a 21.88,21.88 0 0 1 -3,11 L 204.7,339.3 a 8,8 0 0 1 -3.9,-6.86 V 290 a 8,8 0 0 1 8,-8 z"
id="path68" />
<path
class="cls-2" fill="#3a3a3a"
d="m 374.11,275.9 a 18,18 0 0 1 18,18 v 311.39 a 18.05,18.05 0 0 1 -18,18 H 128.65 a 18.05,18.05 0 0 1 -18,-18 v -225.7 a 18,18 0 0 1 18,-18 h 69.14 a 26.05,26.05 0 0 0 26,-26 v -41.6 a 18,18 0 0 1 18,-18 h 132.32 m 0,-4 H 241.85 a 22,22 0 0 0 -22,22 v 41.6 a 22,22 0 0 1 -22,22 h -69.2 a 22,22 0 0 0 -22,22 v 225.7 a 22,22 0 0 0 22,22 h 245.46 a 22,22 0 0 0 22,-22 V 293.93 a 22,22 0 0 0 -22,-22 z"
id="path69" />
</g>
<g
id="Layer_7"
data-name="Layer 7"
transform="translate(-106,-157.045)">
<path
class="cls-11" stroke-width="1.3px"
d="m 396.14,293.93 v 311.35 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 V 379.59 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.66 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,22 z"
id="path70" />
<rect
class="cls-11" stroke-width="1.3px"
x="118.67"
y="378.97"
width="268.88"
height="233.91"
rx="28.639999"
id="rect70" />
<rect
class="cls-11" stroke-width="1.3px"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect71" />
<rect
class="cls-11" stroke-width="1.3px"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect72" />
<path
class="cls-11" stroke-width="1.3px"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path72" />
<path
class="cls-16" stroke-width="1.01px"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61"
id="path73" />
<rect
class="cls-10" fill="#d1b066"
x="322.39001"
y="253.42999"
width="37.77"
height="10.45"
id="rect73" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path74" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path75" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path76" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path77" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path78" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path79" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path80" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path81" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path82" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path83" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path84" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path85" />
<rect
class="cls-10" fill="#d1b066"
x="328.20001"
y="217.71001"
width="26.27"
height="4.9099998"
id="rect85" />
<rect
class="cls-11" stroke-width="1.3px"
x="322.63"
y="253.42999"
width="18.77"
height="10.45"
id="rect86" />
<rect
class="cls-11" stroke-width="1.3px"
x="341.39999"
y="253.42999"
width="18.77"
height="10.45"
id="rect87" />
<rect
class="cls-6" stroke-width="0.95px"
x="328.20001"
y="217.71001"
width="26.27"
height="5.29"
id="rect88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path89" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path90" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path91" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path92" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path93" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path94" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path95" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path96" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path97" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path98" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path99" />
<path
class="cls-11" stroke-width="1.3px"
d="m 384.27,274.39 v 0 -3.79 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31"
id="path100" />
<path
class="cls-11" stroke-width="1.3px"
d="m 223.32,282 h -14.53 a 8,8 0 0 0 -8,8 v 42.43 a 8,8 0 0 0 3.9,6.86 l 12.16,7.25"
id="path101" />
<path
class="cls-6" stroke-width="0.95px"
d="m 212.06,282 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 A 54.48,54.48 0 0 1 370,212 v 51.84"
id="path102" />
<path
class="cls-6" stroke-width="0.95px"
d="M 241.39,269.77 V 209 a 19.48,19.48 0 0 1 19.48,-19.48 h 58.92 A 19.48,19.48 0 0 1 339.27,209 v 8.68"
id="path103" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path104" />
<rect
class="cls-6" stroke-width="0.95px"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect104" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="8.1499996"
id="circle140" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="12.85"
id="circle141" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="8.1499996"
id="circle142" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="12.85"
id="circle143" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="8.1499996"
id="circle144" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="12.85"
id="circle145" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="8.1499996"
id="circle146" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="12.85"
id="circle147" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path147" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path148" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path149" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path150" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path151" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path152" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path153" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path154" />
<path
class="cls-3" fill="#202121"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path155" />
<path
class="cls-3" fill="#202121"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path156" />
<path
class="cls-3" fill="#202121"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path157" />
<path
class="cls-3" fill="#202121"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path158" />
<path
class="cls-3" fill="#202121"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path159" />
<path
class="cls-3" fill="#202121"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path160" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 123 KiB

@@ -1,109 +0,0 @@
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg105" sodipodi:docname="thinknode_m1.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="397.31 77.24 361 863.17">
<sodipodi:namedview id="namedview105" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.066786" inkscape:cx="564.94244" inkscape:cy="741.49463" inkscape:window-width="1472" inkscape:window-height="890" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="Layer_3"/>
<defs id="defs1">
<style id="style1">.cls-1{fill:#353535;}.cls-2{fill:#262626;}.cls-3{fill:#cccccb;}.cls-4{fill:#2b2b2b;}.cls-5{fill:#f05043;}.cls-6{fill:#3d3d3d;}.cls-7{fill:#231f20;}.cls-8{fill:none;stroke:#000;stroke-miterlimit:10;}</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path class="cls-1" fill="#353535" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path1"/>
<rect class="cls-2" fill="#262626" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect1"/>
<rect class="cls-1" fill="#353535" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect2"/>
<rect class="cls-3" fill="#cccccb" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect3"/>
<path class="cls-1" fill="#353535" d="M507.38,77.74H472.16a7,7,0,0,0-7,7V359.93L452.2,396.26v39.91H561V396.26l-13.3-36V84.15a6.41,6.41,0,0,0-6.41-6.41Z" id="path3"/>
<rect class="cls-2" fill="#262626" x="454.25" y="436.17" width="104.38" height="3.65" id="rect4"/>
<polygon class="cls-1" fill="#353535" points="442.68 449.91 448.16 440.69 562.98 440.69 570.51 449.91 442.68 449.91" id="polygon4"/>
<rect class="cls-1" fill="#353535" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.8" id="rect5"/>
<path class="cls-2" fill="#262626" d="M611.2,356v-5.48a3.13,3.13,0,0,1,3.13-3.13h86.35a3.13,3.13,0,0,1,3.13,3.13V356Z" id="path5"/>
<rect class="cls-2" fill="#262626" x="611.07" y="416.61" width="92.74" height="23.22" id="rect6"/>
<polygon class="cls-1" fill="#353535" points="592.99 449.91 598.47 440.69 713.42 440.69 720.82 449.91 592.99 449.91" id="polygon6"/>
<rect class="cls-2" fill="#262626" x="751.94" y="555.13" width="5.87" height="47.48" id="rect7"/>
<path class="cls-2" fill="#262626" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path7"/>
<path class="cls-2" fill="#262626" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path8"/>
<path class="cls-4" fill="#2b2b2b" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path9"/>
<rect class="cls-2" fill="#262626" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect9"/>
<circle class="cls-5" fill="#f05043" cx="518.72" cy="853.89" r="5.48" id="circle9"/>
<circle class="cls-1" fill="#353535" cx="640.14" cy="853.89" r="5.48" id="circle10"/>
<circle class="cls-1" fill="#353535" cx="541.83" cy="853.89" r="5.48" id="circle11"/>
<circle class="cls-1" fill="#353535" cx="567.67" cy="853.89" r="5.48" id="circle12"/>
<circle class="cls-1" fill="#353535" cx="593.51" cy="853.89" r="5.48" id="circle13"/>
<circle class="cls-1" fill="#353535" cx="616.82" cy="853.89" r="5.48" id="circle14"/>
<path class="cls-4" fill="#2b2b2b" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path14"/>
<path class="cls-4" fill="#2b2b2b" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path15"/>
<path class="cls-6" fill="#3d3d3d" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path16"/>
<path class="cls-6" fill="#3d3d3d" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path17"/>
<path class="cls-6" fill="#3d3d3d" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path18"/>
<path class="cls-6" fill="#3d3d3d" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path19"/>
<path class="cls-6" fill="#3d3d3d" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path20"/>
<path class="cls-6" fill="#3d3d3d" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path21"/>
<path class="cls-1" fill="#353535" d="M477,924.32h-3V903.09h-7.46v-2.65h17.9v2.65H477Z" id="path51"/>
<path class="cls-1" fill="#353535" d="M490.59,906.36c0,.43,0,.86,0,1.31s-.07.85-.12,1.2h.2a5.17,5.17,0,0,1,1.44-1.54,7.08,7.08,0,0,1,1.94-.92,7.81,7.81,0,0,1,2.21-.31,8.61,8.61,0,0,1,3.63.68,4.62,4.62,0,0,1,2.19,2.13,8.22,8.22,0,0,1,.73,3.74v11.67h-2.9V912.85a4.72,4.72,0,0,0-1-3.24,3.92,3.92,0,0,0-3.05-1.07,5.65,5.65,0,0,0-3.14.75,4.07,4.07,0,0,0-1.62,2.21,11.17,11.17,0,0,0-.49,3.56v9.26h-2.94V898.91h2.94Z" id="path52"/>
<path class="cls-1" fill="#353535" d="M509.82,899.67a1.73,1.73,0,0,1,1.19.46,2.17,2.17,0,0,1,0,2.82,1.74,1.74,0,0,1-1.19.47,1.77,1.77,0,0,1-1.24-.47,2.24,2.24,0,0,1,0-2.82A1.76,1.76,0,0,1,509.82,899.67Zm1.44,6.73v17.92h-2.94V906.4Z" id="path53"/>
<path class="cls-1" fill="#353535" d="M525.58,906.06a6.8,6.8,0,0,1,4.85,1.56c1.09,1,1.63,2.71,1.63,5v11.67h-2.91V912.85a4.67,4.67,0,0,0-1-3.24,3.88,3.88,0,0,0-3-1.07c-2,0-3.36.56-4.11,1.67a8.54,8.54,0,0,0-1.14,4.82v9.29H517V906.4h2.37l.44,2.44h.16a5.68,5.68,0,0,1,1.49-1.56,6.41,6.41,0,0,1,2-.92A8.13,8.13,0,0,1,525.58,906.06Z" id="path54"/>
<path class="cls-1" fill="#353535" d="M540.53,912.18c0,.36,0,.83,0,1.41s-.07,1.08-.09,1.5h.14l.6-.77.82-1c.28-.34.52-.63.72-.85l5.72-6.05h3.44l-7.26,7.66,7.76,10.26h-3.54L542.57,916l-2,1.78v6.58h-2.91V898.91h2.91Z" id="path55"/>
<path class="cls-1" fill="#353535" d="M574.81,924.32H571.3l-12.78-19.83h-.13c0,.51.08,1.12.11,1.82s.07,1.45.1,2.24,0,1.61,0,2.43v13.34h-2.77V900.44h3.48l12.74,19.77h.13c0-.36-.05-.89-.08-1.6s-.07-1.5-.1-2.35,0-1.62,0-2.34V900.44h2.81Z" id="path56"/>
<path class="cls-1" fill="#353535" d="M596.48,915.33a12.32,12.32,0,0,1-.58,4,8.32,8.32,0,0,1-1.67,2.93,7,7,0,0,1-2.65,1.82,9.31,9.31,0,0,1-3.46.62,8.63,8.63,0,0,1-3.28-.62,7.29,7.29,0,0,1-2.61-1.82,8.57,8.57,0,0,1-1.72-2.93,11.76,11.76,0,0,1-.62-4,11.43,11.43,0,0,1,1-5,7.12,7.12,0,0,1,2.87-3.14,8.76,8.76,0,0,1,4.45-1.09,8.4,8.4,0,0,1,4.3,1.09,7.45,7.45,0,0,1,2.91,3.14A11,11,0,0,1,596.48,915.33Zm-13.54,0a10.93,10.93,0,0,0,.55,3.66,4.82,4.82,0,0,0,1.72,2.39,5.69,5.69,0,0,0,5.95,0,4.78,4.78,0,0,0,1.73-2.39,10.93,10.93,0,0,0,.55-3.66,10.36,10.36,0,0,0-.57-3.65,4.84,4.84,0,0,0-1.72-2.32,5,5,0,0,0-3-.82,4.5,4.5,0,0,0-4,1.8A8.79,8.79,0,0,0,582.94,915.33Z" id="path57"/>
<path class="cls-1" fill="#353535" d="M607.49,924.66a6.67,6.67,0,0,1-5.35-2.33c-1.34-1.54-2-3.86-2-6.94s.67-5.4,2-7a6.74,6.74,0,0,1,5.37-2.36,7.71,7.71,0,0,1,2.44.35,6.37,6.37,0,0,1,1.81,1,6.58,6.58,0,0,1,1.3,1.34h.2c0-.29-.06-.72-.11-1.29s-.09-1-.09-1.36v-7.15H616v25.41h-2.38l-.43-2.4h-.14a6.51,6.51,0,0,1-1.3,1.38,5.9,5.9,0,0,1-1.82,1A7.4,7.4,0,0,1,607.49,924.66Zm.46-2.44c1.9,0,3.23-.52,4-1.56a7.84,7.84,0,0,0,1.16-4.7v-.53a9.84,9.84,0,0,0-1.11-5.14c-.73-1.19-2.09-1.79-4.08-1.79a4,4,0,0,0-3.56,1.89,9.46,9.46,0,0,0-1.19,5.07,9,9,0,0,0,1.19,5A4,4,0,0,0,608,922.22Z" id="path58"/>
<path class="cls-1" fill="#353535" d="M628.62,906.06a7.53,7.53,0,0,1,4,1,6.62,6.62,0,0,1,2.54,2.82,9.69,9.69,0,0,1,.89,4.27v1.77H623.74a6.75,6.75,0,0,0,1.56,4.63,5.42,5.42,0,0,0,4.16,1.59,12.58,12.58,0,0,0,3-.32,16.78,16.78,0,0,0,2.72-.92v2.58a13.84,13.84,0,0,1-2.71.89,16.15,16.15,0,0,1-3.17.28,9.46,9.46,0,0,1-4.5-1,7.15,7.15,0,0,1-3-3.09,10.61,10.61,0,0,1-1.09-5,12,12,0,0,1,1-5,7.33,7.33,0,0,1,6.94-4.38Zm0,2.41a4.26,4.26,0,0,0-3.33,1.36,6.34,6.34,0,0,0-1.45,3.76h9.13a7.05,7.05,0,0,0-.47-2.68,3.94,3.94,0,0,0-1.42-1.79A4.33,4.33,0,0,0,628.59,908.47Z" id="path59"/>
<path class="cls-1" fill="#353535" d="M639.36,913h8.1v2.74h-8.1Z" id="path60"/>
<path class="cls-1" fill="#353535" d="M662.87,924.32,655,903.39h-.13c0,.44.08,1,.12,1.7s.06,1.45.08,2.26,0,1.65,0,2.49v14.48h-2.77V900.44h4.45L664.15,920h.13l7.49-19.57h4.42v23.88h-3V909.64c0-.78,0-1.55,0-2.32s.06-1.5.1-2.18.08-1.25.1-1.72h-.13l-8,20.9Z" id="path61"/>
<path class="cls-1" fill="#353535" d="M688.16,924.32V909.41c0-.63,0-1.3,0-2s0-1.42.07-2.1,0-1.27,0-1.76c-.35.38-.66.69-.92.92s-.6.54-1,.92l-2.41,2-1.57-2,6.26-4.89H691v23.88Z" id="path62"/>
<path class="cls-1" fill="#353535" d="M502.55,894.19l-2.22-2.37a14.1,14.1,0,0,1,18.94-.33l-2.13,2.44a10.9,10.9,0,0,0-7.16-2.69A10.78,10.78,0,0,0,502.55,894.19Z" id="path63"/>
<path class="cls-1" fill="#353535" d="M506.38,897.85l-2.4-2.18a8.08,8.08,0,0,1,11.61-.39l-2.24,2.33a4.85,4.85,0,0,0-7,.24Z" id="path64"/>
</g>
<g id="Layer_2" data-name="Layer 2">
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M472.55,77.74h68.09a7.43,7.43,0,0,1,7.43,7.43V360.26a0,0,0,0,1,0,0h-83a0,0,0,0,1,0,0V85.17A7.43,7.43,0,0,1,472.55,77.74Z" id="path65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="123.91" x2="548.07" y2="123.91" id="line65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="149.74" x2="548.07" y2="149.74" id="line66"/>
<polyline class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" points="465.12 360.26 452.2 396.26 452.2 436.17 560.99 436.17 560.99 396.26 548.07 360.26" id="polyline66"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="452.2" y1="396.26" x2="560.98" y2="396.26" id="line67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M449.69,440.17H562a3.26,3.26,0,0,1,2.56,1.55l5.93,8.19H442.68l4-7.49A3.65,3.65,0,0,1,449.69,440.17Z" id="path67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M600,440.17H712.33a3.24,3.24,0,0,1,2.56,1.55l5.93,8.19H593l4-7.49A3.65,3.65,0,0,1,600,440.17Z" id="path68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="454.45" y1="436.17" x2="454.45" y2="439.83" id="line68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="558.64" y1="436.17" x2="558.64" y2="439.83" id="line69"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.87" id="rect69"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="611.07" y1="416.61" x2="611.07" y2="439.83" id="line70"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="703.81" y1="416.61" x2="703.81" y2="439.83" id="line71"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M614.2,347.35h86.48a3.13,3.13,0,0,1,3.13,3.13V356a0,0,0,0,1,0,0H611.07a0,0,0,0,1,0,0v-5.48A3.13,3.13,0,0,1,614.2,347.35Z" id="path71"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="570.51" y1="449.91" x2="592.99" y2="449.91" id="line72"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect73"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path75"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path76"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path77"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path78"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path79"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="751.94" y="555.13" width="5.87" height="47.48" id="rect79"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path80"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path81"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path82"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="518.72" cy="853.89" r="5.48" id="circle82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="640.14" cy="853.89" r="5.48" id="circle83"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="541.83" cy="853.89" r="5.48" id="circle84"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="567.67" cy="853.89" r="5.48" id="circle85"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="593.51" cy="853.89" r="5.48" id="circle86"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="616.82" cy="853.89" r="5.48" id="circle87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="572.74" x2="430.68" y2="602.61" id="line87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="595.43" x2="424.42" y2="578.11" id="line88"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="595.43" x2="438.21" y2="578.11" id="line89"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="644.74" x2="430.68" y2="674.61" id="line90"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="667.43" x2="424.42" y2="650.11" id="line91"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="667.43" x2="438.21" y2="650.11" id="line92"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="716.74" x2="430.68" y2="746.61" id="line93"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="739.43" x2="424.42" y2="722.11" id="line94"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="739.43" x2="438.21" y2="722.11" id="line95"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="572.74" x2="730.03" y2="602.61" id="line96"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="595.43" x2="723.77" y2="578.11" id="line97"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="595.43" x2="737.56" y2="578.11" id="line98"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="644.74" x2="730.03" y2="674.61" id="line99"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="667.43" x2="723.77" y2="650.11" id="line100"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="667.43" x2="737.56" y2="650.11" id="line101"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="716.74" x2="730.03" y2="746.61" id="line102"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="739.43" x2="723.77" y2="722.11" id="line103"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="739.43" x2="737.56" y2="722.11" id="line104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path105"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -1,264 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 233.57587 210"
fill="none"
version="1.1"
id="svg22"
sodipodi:docname="unknown-new.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="233.57587"
height="210"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6448247"
inkscape:cx="116.45384"
inkscape:cy="98.116143"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg22" />
<!-- Antenna -->
<!-- Radio body - main housing with gradient -->
<defs
id="defs6">
<linearGradient
id="bodyGrad"
x1="29.580399"
y1="34.651325"
x2="207.06279"
y2="212.13371"
gradientTransform="matrix(0.84515425,0,0,1.183216,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#22222a"
id="stop2" />
<stop
offset="100%"
style="stop-color:#16161b"
id="stop3" />
</linearGradient>
<linearGradient
id="screenGrad"
x1="36.829224"
y1="105.71089"
x2="36.829224"
y2="201.38222"
gradientTransform="matrix(1.1811273,0,0,0.84664878,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#0c1a12"
id="stop4" />
<stop
offset="100%"
style="stop-color:#061008"
id="stop5" />
</linearGradient>
<filter
id="glow"
x="-0.8"
y="-0.8"
width="2.6"
height="2.6">
<feGaussianBlur
stdDeviation="2"
result="coloredBlur"
id="feGaussianBlur5" />
<feMerge
id="feMerge6">
<feMergeNode
in="coloredBlur"
id="feMergeNode5" />
<feMergeNode
in="SourceGraphic"
id="feMergeNode6" />
</feMerge>
</filter>
</defs>
<rect
x="46.289543"
y="5"
width="140"
height="200"
rx="14"
fill="url(#bodyGrad)"
stroke="#67ea94"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"
id="rect6"
style="fill:url(#bodyGrad)" />
<!-- Inner bezel -->
<rect
x="54.289543"
y="13"
width="124"
height="184"
rx="10"
fill="none"
stroke="#2a2a32"
stroke-width="1"
id="rect7" />
<!-- Top speaker grille -->
<rect
x="71.289543"
y="21"
width="90"
height="20"
rx="3"
fill="#0a0a0f"
id="rect8" />
<g
stroke="#333340"
stroke-width="1"
id="g10"
transform="translate(16.289544,-41)">
<line
x1="62"
y1="68"
x2="138"
y2="68"
id="line8" />
<line
x1="62"
y1="72"
x2="138"
y2="72"
id="line9" />
<line
x1="62"
y1="76"
x2="138"
y2="76"
id="line10" />
</g>
<!-- Screen area with glow effect -->
<rect
x="60.289543"
y="49"
width="112"
height="80"
rx="6"
fill="url(#screenGrad)"
stroke="#67ea94"
stroke-width="1"
opacity="0.8"
id="rect10"
style="fill:url(#screenGrad)" />
<!-- Meshtastic logo centered in screen - using actual logo paths -->
<g
transform="matrix(0.95,0,0,0.95,69.289544,64)"
filter="url(#glow)"
id="g14">
<g
transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)"
id="g13">
<g
transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)"
id="g11">
<path
d="m 250.908,330.267 -57.782,84.738 -12.188,-8.311 63.864,-93.657 c 1.372,-2.013 3.651,-3.218 6.087,-3.221 2.437,-0.002 4.717,1.199 6.093,3.21 l 64.012,93.51 -12.173,8.333 z"
fill="#67ea94"
id="path10" />
</g>
<g
transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)"
id="g12">
<path
d="m 87.642,581.398 67.115,-98.421 -12.119,-8.264 -67.115,98.421 z"
fill="#67ea94"
id="path11" />
</g>
</g>
</g>
<!-- Question mark with glow -->
<!-- Control area background -->
<rect
x="60.289543"
y="137"
width="112"
height="60"
rx="6"
fill="#0f0f14"
id="rect14" />
<!-- D-pad style navigation -->
<g
transform="translate(88.289544,167)"
id="g17">
<circle
cx="0"
cy="0"
r="22"
fill="#1a1a20"
stroke="#2a2a32"
stroke-width="1"
id="circle14" />
<path
d="M -6,-16 H 6 V -6 H -6 Z"
fill="#2a2a32"
rx="1"
id="path14" />
<path
d="M -6,6 H 6 V 16 H -6 Z"
fill="#2a2a32"
rx="1"
id="path15" />
<path
d="M -16,-6 H -6 V 6 h -10 z"
fill="#2a2a32"
rx="1"
id="path16" />
<path
d="M 6,-6 H 16 V 6 H 6 Z"
fill="#2a2a32"
rx="1"
id="path17" />
<circle
cx="0"
cy="0"
r="5"
fill="#3a3a42"
id="circle17" />
</g>
<!-- Action button (glowing green) -->
<circle
cx="144.28955"
cy="167"
r="14"
fill="#1a2a1f"
stroke="#67ea94"
stroke-width="2"
id="circle18" />
<circle
cx="144.28955"
cy="167"
r="8"
fill="#67ea94"
filter="url(#glow)"
id="circle19" />
<!-- Status LED -->
<circle
cx="170.26186"
cy="25.547974"
r="3"
fill="#67ea94"
opacity="0.8"
filter="url(#glow)"
id="circle20" />
<!-- Side buttons -->
<!-- USB port indication at bottom -->
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+2 -1
View File
@@ -439,7 +439,8 @@
start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); },
request: function (offer, options) {
if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.'));
return rentalRequest('request', { offer: offer }, 600000, options.signal);
if (options.renewExpired !== undefined && typeof options.renewExpired !== 'boolean') return Promise.reject(new Error('Invalid rental renewal request.'));
return rentalRequest('request', { offer: offer, renewExpired: options.renewExpired === true }, 600000, options.signal);
}
};
Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true });
+5 -2
View File
@@ -88,7 +88,10 @@ async function createSession() {
error.value = ''
try {
const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
if (!response.ok) throw new Error('Could not create a developer session.')
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: string } | null
throw new Error(payload?.error || `Could not create a developer session (${response.status}).`)
}
const session = await response.json()
sessions.value = [session, ...sessions.value]
selectedId.value = session.id
@@ -215,6 +218,6 @@ onBeforeUnmount(() => {
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
.terminal-empty { display: flex; flex: 1 1 100%; width: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
</style>

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