Compare commits

..
66 Commits
Author SHA1 Message Date
archipelago 969570e38b chore: prepare release v1.8.15-alpha
Demo images / Build & push demo images (push) Successful in 3m29s
2026-09-13 04:32:16 -04:00
archipelago b73d646db5 docs: prepare 1.8.15 alpha notes
Demo images / Build & push demo images (push) Successful in 3m22s
2026-09-13 03:06:53 -04:00
archipelago 8c37ff412c fix: present Cuprate as one launchable app
Demo images / Build & push demo images (push) Successful in 3m28s
2026-09-13 03:06:22 -04:00
archipelago 06bf359535 chore: publish release v1.8.14-alpha 2026-09-13 02:56:29 -04:00
archipelago a4f3415f0f chore: prepare release v1.8.14-alpha
Demo images / Build & push demo images (push) Successful in 3m25s
2026-09-13 02:53:11 -04:00
archipelago c9c9ebe6d4 docs: sync whats new for 1.8.14 alpha
Demo images / Build & push demo images (push) Successful in 3m18s
2026-09-13 02:34:45 -04:00
archipelago 100993445b docs: prepare 1.8.14 alpha release notes 2026-09-13 02:33:30 -04:00
archipelago a4f80e7ec1 test: update GitWorkshop launcher deep-link expectation
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:48:36 -04:00
archipelago 4ad34d3a0a test: validate full Archipelago ngit promotion path 2026-09-13 01:45:22 -04:00
archipelago c9bae926a5 test: satisfy strict indexed access
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:41:11 -04:00
archipelago cb3f7e8720 Merge PR #157: Cuprate disk gate and companion dashboard
Demo images / Build & push demo images (push) Successful in 3m19s
2026-09-13 01:37:46 -04:00
archipelago eb98ebb682 Merge PR #158: preserve Bitcoin Core Tor service naming 2026-09-13 01:37:15 -04:00
archipelago 00682e6420 test: expect Cuprate launches through companion UI 2026-09-12 16:17:47 -04:00
archipelago 95cdc3daea fix: retain source port in Cuprate generated ports 2026-09-12 16:15:28 -04:00
archipelago 1d05f2c27a style: format generated app launch ports 2026-09-12 16:15:01 -04:00
archipelago b3f16d07a6 style(cuprate-ui): anchor desktop details to right edge 2026-09-12 16:14:55 -04:00
archipelago 14d2b37e99 style(cuprate-ui): right-align desktop header cards 2026-09-12 16:14:55 -04:00
archipelago f5b255ee68 style(cuprate-ui): improve mobile dashboard layout 2026-09-12 16:14:55 -04:00
archipelago 6e8d90fb5f style(cuprate-ui): match bitcoin status cards 2026-09-12 16:14:55 -04:00
archipelago 66c4b0d375 feat(cuprate-ui): add bitcoin-style dashboard tabs 2026-09-12 16:14:55 -04:00
archipelago 0f74ebfbbe feat(cuprate-ui): use app icon and shared dashboard background 2026-09-12 16:14:55 -04:00
archipelago ee11863ada refactor(cuprate-ui): align dashboard with bitcoin UI style 2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 86052d9552 refactor(cuprate): one CUPRATE_MIN_DISK_GB, manifest matches it (review)
450 existed as two independent Rust constants (RPC gates vs boot
reconciler) linked only by a "keep in lockstep" comment — updating one
would reopen the disk-fill hole. Move it to crate::constants as the
single source of truth both paths import.

Also raise apps/cuprate/manifest.yml storage dependency and disk_limit
from 300Gi to 450Gi so manifest-driven surfaces (store size, pre-checks)
show the number the gate actually enforces — a user provisioning to the
displayed 300 was refused at an unexplained 450. Catalog regenerated
(cuprate entry re-embedded; still unsigned pending sign-catalog.sh).
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 047ef98987 fix(cuprate-ui): target_height 0 means synced, not stuck (review)
Monero's get_info returns target_height == 0 when the node is FULLY
SYNCED — the field is the height being caught up to, not the chain tip.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago c681472e15 fix(cuprate): gate package.restart and package.update too (review)
Restart and update are stop + recreate — a fresh start by another name —
but only start carried the disk gate, so on a disk that shrank below the
floor after install, either action silently resumed the unprunable
Monero sync: the exact failure the gate exists to close.

Both now call check_cuprate_disk_compatibility after validate_app_id and
BEFORE any state mutation (user-stopped clear / Restarting / Updating
flip), matching handle_package_start's fail-clean contract.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 7c0ba14a00 feat(neode-ui): launch cuprate tiles on the Cuprate UI companion
cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago eacd74e1db feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.

No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.

Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
2026-09-12 16:14:29 -04:00
ssmithxandarchipelago 34b68001d1 fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.

Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
  actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
  ~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
  recorded as Left("cuprate-insufficient-disk") before ensure_running
  so desired-state recovery can never undo it (same shape as
  requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
  fail-closes at boot (never start a doomed sync)

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-12 16:14:02 -04:00
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
archipelago fbb3ada87d chore: publish release v1.8.13-alpha
Demo images / Build & push demo images (push) Successful in 3m46s
2026-09-12 06:44:01 -04:00
archipelago 72e84439ee chore: prepare release v1.8.13-alpha 2026-09-12 06:40:21 -04:00
archipelago 5081a4fe7d docs: expand v1.8.13-alpha release notes 2026-09-12 05:41:13 -04:00
archipelago 39727dacbc style: format generated app ports 2026-09-12 05:37:55 -04:00
archipelago 1e409007d4 chore: regenerate app port metadata 2026-09-12 05:05:37 -04:00
archipelago 8f144c3038 chore: remove retired AdGuard app and refresh release docs 2026-09-12 05:05:33 -04:00
archipelago 8258705df7 chore: sync v1.8.13-alpha whats new 2026-09-12 04:44:34 -04:00
archipelago d13002e022 docs: add v1.8.13-alpha release notes 2026-09-12 04:44:20 -04:00
archipelago e625b29d9e fix: route GitWorkshop installs through orchestrator 2026-09-12 04:41:14 -04:00
archipelago c4ed9fb1fa release: sign app catalog for v1.8.12-alpha 2026-09-12 04:16:21 -04:00
archipelago 2bc5e98edb chore: publish release v1.8.12-alpha
Demo images / Build & push demo images (push) Successful in 3m49s
2026-09-11 15:17:17 -04:00
archipelago c1e14f7c7a chore: prepare release v1.8.12-alpha 2026-09-11 15:13:40 -04:00
archipelago 564ffe1c47 fix(indeedhub): generate per-node encryption root 2026-09-11 11:25:44 -04:00
archipelago c34d6ef76f docs(release): finalize 1.8.12 notes
Demo images / Build & push demo images (push) Successful in 4m22s
2026-09-11 06:55:17 -04:00
archipelago dac29baf97 fix(release): surface companion build and secure GitWorkshop deps 2026-09-11 06:10:59 -04:00
archipelago ef8c3a76be chore(release): define 1.8.12 publication gates 2026-09-11 05:37:21 -04:00
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
archipelago f5c0ba85cd feat(release): stage GitWorkshop and next node updates 2026-09-09 18:15:21 -04:00
archipelago 973356df16 fix(ecash): harden Minibits claim persistence 2026-09-08 21:16:57 -04:00
e5a0d95459 fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a
registered @minibits.cash address: POST /claim (the only claim source
claim_and_redeem checked) always returned an empty array, no matter
how long or how often it was polled. Independently queried
wss://relay.minibits.cash and found all three payments sitting there
as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr
pubkey and authored by the Minibits service key — that is the actual
delivery channel for a payment made to the address, and this module
never looked at it.

fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus
two public fallbacks) for kind-4 events tagged to our pubkey, feeding
matching content into the existing pending_claims retry pipeline
unchanged. A new last_dm_seen_at watermark stops the same (immutable,
never-expiring) relay event from being re-fetched and re-attempted on
every poll. The REST /claim call stays in place alongside it in case
it serves some other payment path — this only adds the missing one.

fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token

Once the relay fix above surfaced the three real payments, all three
failed to redeem with "Invalid base64 in cashuB token" — the decrypted
NIP-04 content had a trailing space after the base64 payload (Minibits'
own encoding), which every base64 alphabet in decode_token_base64
rejects outright. CashuToken::deserialize now trims the whole token
string before touching the "cashuA"/"cashuB" prefix or payload. This is
a general robustness fix, not just a Minibits workaround — the same
stray-whitespace failure could hit a hand-pasted token from a clipboard
copy just as easily.

Both fixes verified end-to-end against production: all three stuck
payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll
after deploying this build to archy-x250-pa3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
b9862c7643 fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).

Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
6fe9c5f81b fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
28454264ac test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close
the whole Receive modal. Added a regression test simulating the exact
click, both for wallet.ecash-lnaddress succeeding and failing — the
tab switch alone never emits `close` or unmounts the dialog in either
case, so this isn't reproduced by a plain component-level click; the
investigation continues with the reporter for a browser-console repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
84b04d1634 fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since
removed) while a client had the ecash receive tab open. save_state's
write landed mid-truncate, leaving wallet/minibits.json at 0 bytes.
load_state then hard-failed every wallet.ecash-lnaddress call with
"EOF while parsing a value", surfaced in the UI as "Lightning address
unavailable" — permanently, since nothing ever cleared the bad file.

Registration is idempotent per pubkey (re-registering returns the same
lud16 Minibits already assigned), so there's no reason a corrupt local
mirror of that state should be fatal. load_state now treats an empty
or unparseable state file the same as a missing one — re-register and
recover the same address — instead of erroring. Manually cleared the
stuck file on archy-x250-pa3 as an immediate fix; this closes the gap
so it self-heals next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ce5c04d49d fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ssmithxandarchipelago ce9fca1c38 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 21:16:57 -04:00
archipelago e661f237f1 fix(openwrt): harden TollGate PR integration 2026-09-08 21:06:36 -04:00
f9af30b08a feat(openwrt): make TollGate payout Lightning address configurable
Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
87a5025341 docs(tollgate-sweep): document two live-confirmed drain-CLI bugs
sweep_once() has never actually swept anything: `tollgate wallet drain
cashu` (no flags) blocks on an interactive y/N confirmation that Router::run
can never answer over a non-PTY SSH exec (empty stdin -> EOF -> defaults to
N -> "Operation cancelled." with exit code 0), so the drain_code != 0 check
can't catch it and every tick silently no-ops.

The obvious fix isn't safe either: `--json` skips the prompt, but confirmed
live against archy-x250-pa3 that on a wallet.db with a stale duplicate
per-mint entry (trailing-slash leftover from before the mint_url fix), it
completes a real swap against the good entry, then aborts on the second
(empty, stale) entry and reports "success": false without ever printing or
persisting the resulting token anywhere. 50 sats went from spendable balance
to gone in that one call. Documented so nobody "fixes" this by wiring in
--json before upstream fixes the partial-failure data loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
2947277205 fix(openwrt): close TollGate free-access gap and mint URL mismatch
Two bugs found live against archy-x250-pa3: TollGate-3458 (the upstream
tollgate-module-basic-go installer's own default AP, rebranded from
OpenWrt's factory default wireless.default_radioN sections) was left
bound to `network=lan` — wide open, unmetered, and sharing the router's
admin LAN — because install_ipk() runs the upstream package's own
uci-defaults scripts but nothing reconciled the AP they create with the
separate `tollgate` network/bridge/firewall this project's own
provision_ssid() sets up for the "archipelago" SSID. Fixed by folding any
default_radioN section left on `lan` onto the `tollgate` network right
after it's created.

Separately, a caller-supplied mint_url with a trailing slash
(https://mint.minibits.cash/Bitcoin/) got written byte-for-byte into
accepted_mints[0].url, which tollgate-wrt string-compares exactly against
a token's embedded (slash-less) mint URL — rejecting every otherwise-valid
token as an "untrusted mint". Fixed by trimming trailing slashes before
the value is used anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
151 changed files with 10518 additions and 1753 deletions
+22 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 48
versionName = "0.5.28"
versionCode = 52
versionName = "0.5.32"
vectorDrawables {
useSupportLibrary = true
@@ -41,6 +41,17 @@ android {
enableV1Signing = true
enableV2Signing = true
}
// Local-only UAT builds install beside both the production companion
// and its shared-key debug package. The ignored uat.keystore is made
// on the validation box; it must never be used for a public artifact.
create("uat") {
storeFile = file("uat.keystore")
storePassword = "android"
keyAlias = "androiduatkey"
keyPassword = "android"
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
@@ -51,6 +62,13 @@ android {
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
create("uat") {
initWith(getByName("debug"))
applicationIdSuffix = ".uat"
versionNameSuffix = "-uat"
signingConfig = signingConfigs.getByName("uat")
matchingFallbacks += listOf("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -118,8 +136,8 @@ tasks.register<Exec>("buildRustArm64") {
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
@@ -326,8 +326,9 @@ private object KioskWebView {
private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
val sab = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom / density).toInt()
// The insets listener fires on every pass (every IME show/hide); skip the
// JS round-trip — and the Vue event it dispatches — when nothing changed.
val stamp = "sa:$sat,$sab"
@@ -377,7 +378,8 @@ private fun injectSafeAreaVars(view: WebView) {
private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
if (sat <= 0) return
view.evaluateJavascript(
"""
@@ -991,6 +993,51 @@ fun WebViewScreen(
)
}
}
/** HTML downloads are not handled by WebView.
* Fetch only this connected node's public CA
* over its always-available HTTP listener,
* verify it is an actual CA certificate, then
* hand it to Android's trusted system prompt.
* No caller-controlled certificate bytes are
* accepted by this bridge. */
@android.webkit.JavascriptInterface
fun installNodeCertificate() {
scope.launch {
try {
val der = withContext(Dispatchers.IO) {
val host = android.net.Uri.parse(serverUrl).host
?: error("node URL has no host")
val caUrl = java.net.URI(
"http", null, host, 80, "/ca.crt", null, null,
).toASCIIString()
val request = okhttp3.Request.Builder().url(caUrl).build()
okhttp3.OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) error("CA download failed")
val bytes = response.body?.bytes() ?: error("empty CA")
if (bytes.size > 64 * 1024) error("CA is too large")
val cert = java.security.cert.CertificateFactory
.getInstance("X.509")
.generateCertificate(java.io.ByteArrayInputStream(bytes))
as java.security.cert.X509Certificate
if (cert.basicConstraints < 0) error("certificate is not a CA")
cert.encoded
}
}
val intent = android.security.KeyChain.createInstallIntent().apply {
putExtra(android.security.KeyChain.EXTRA_CERTIFICATE, der)
putExtra(
android.security.KeyChain.EXTRA_NAME,
"Archipelago node CA",
)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {
// Network failure, invalid CA, or no credential installer.
}
}
}
},
"ArchipelagoNative",
)
@@ -1523,6 +1570,11 @@ private fun InAppBrowser(
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
// Once this WebView has painted an app, keep that surface visible during
// same-app reloads/navigation. Covering every navigation with an opaque
// Compose loader caused GitWorkshop to flash, and an IndeeHub auth reload
// could remain covered when WebView omitted the final callback.
var hasCommittedPage by remember { mutableStateOf(false) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Main-frame load failure — the branded offline screen renders instead of
@@ -1594,6 +1646,20 @@ private fun InAppBrowser(
// Node apps (BTCPay invoices, LND, Portainer tokens) are
// served over plain HTTP too — same dead-clipboard trap.
addClipboardBridge()
val appBrowserView = this
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun expectPageTransition() {
appBrowserView.post {
hasCommittedPage = false
loading = true
appBrowserView.invalidate()
}
}
},
"ArchipelagoSurface",
)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
@@ -1623,7 +1689,7 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
loading = !hasCommittedPage
loadError = false
view?.let {
injectTopInset(it)
@@ -1632,6 +1698,7 @@ private fun InAppBrowser(
}
override fun onPageFinished(view: WebView?, u: String?) {
hasCommittedPage = true
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
@@ -1641,6 +1708,14 @@ private fun InAppBrowser(
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
// Fires when the new main-frame pixels are ready,
// earlier and more reliably than onPageFinished
// for service-worker-controlled SPAs.
hasCommittedPage = true
loading = false
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
@@ -1732,6 +1807,7 @@ private fun InAppBrowser(
text = stringResource(R.string.retry),
onClick = {
loadError = false
hasCommittedPage = false
loading = true
browser?.reload()
},
+73 -1
View File
@@ -1,5 +1,77 @@
# Changelog
## Unreleased
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
## v1.8.14-alpha (2026-09-13)
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
## v1.8.13-alpha (2026-09-12)
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
## v1.8.12-alpha (2026-09-11)
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
## v1.8.11-alpha (2026-09-07)
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
@@ -52,7 +124,7 @@
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
+8 -1
View File
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
no software license. Archipelago applies a documented integration patch and
redistributes the resulting static application under an explicit owner risk
acceptance dated 2026-09-11; this notice does not claim or grant upstream
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
Jellyfin, MariaDB, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
+15 -1
View File
@@ -11,7 +11,21 @@ Podman containers managed by the Rust backend.
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
[![Version](https://img.shields.io/badge/version-1.8.13--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
## Current release
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
The same source is mirrored through ngit for Nostr-native cloning and
contribution:
```
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Clone with ngit, or use the Gitea mirror when you need a conventional Git
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
## What is here
+34
View File
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container.
## Storefront layout
Discovery merchandising is app-registry data, not node-OS layout. The optional
top-level `storefront` block defines the ordered Popular Apps rows and the
promotional banners placed before the remaining `All Apps` grid:
```json
{
"storefront": {
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
"promotions": [{
"id": "my-app",
"banner": "/assets/img/featured/my-app.webp",
"eyebrow": "open source",
"headline": "Build together.",
"description": "Catalog-controlled promotional copy.",
"tag": "NOSTR // SOURCE",
"path": "/npub1maintainer/project",
"launchLabel": "Open",
"installLabel": "Install",
"detailsLabel": "Learn more →"
}]
}
}
```
Only IDs present in `apps` render. An optional promotion `path` deep-links into
the installed app; Archipelago uses this to open the canonical signed Nostr
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
daemon-verified signed catalog and use the bundled community copy as a local
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
release artifact; changing it does not require a node OS release once that
artifact is published.
## Categories
money, commerce, data, home, nostr, networking, community, development, l484
+37 -12
View File
@@ -9,19 +9,31 @@
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{
"id": "alby-hub",
"title": "Alby Hub",
@@ -247,6 +259,19 @@
},
"tier": "optional"
},
{
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
"author": "GitWorkshop contributors",
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{
"id": "grafana",
"title": "Grafana",
+2
View File
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
## Development Ports (Offset: +10000)
@@ -53,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 |
| GitWorkshop | http://localhost:18337 |
## Port Conflict Resolution
-91
View File
@@ -1,91 +0,0 @@
app:
id: adguardhome
name: AdGuard Home
version: v0.107.79
upstream:
kind: github
repo: AdguardTeam/AdGuardHome
description: >-
Network-wide ad and tracker blocking: a DNS server that filters every
device on your LAN, with a web console for rules and client management.
container:
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 3030
container: 3000
protocol: tcp
bind: 127.0.0.1
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
# node, and both being installable means the host ports must not
# collide (the orchestrator refuses/loads warn on overlap).
# open: the setup wizard and admin console carry AdGuard Home's own
# login; the gate fronts the port (TLS, header fixes) without a
# second cookie challenge.
auth: open
auth_rationale: >-
AdGuard Home enforces its own admin login on the console, and the
first-run wizard must answer before any account exists.
- host: 53
container: 53
protocol: udp
# none: plain DNS must answer every unauthenticated query from LAN
# devices — a login page in front of :53 breaks every client on the
# network by design.
auth: none
auth_rationale: >-
Plain DNS answers unauthenticated by protocol: resolvers and clients
send queries directly; a login challenge would make DNS unreachable.
- host: 53
container: 53
protocol: tcp
auth: none
auth_rationale: >-
DNS-over-TCP fallback (truncated responses, zone transfers); same
protocol-level requirement as the UDP port.
volumes:
- type: bind
source: /var/lib/archipelago/adguardhome
target: /opt/adguardhome
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:3030
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin console
description: AdGuard Home web console
type: ui
port: 3030
protocol: http
path: /
metadata:
author: AdGuard
category: networking
repo: https://github.com/AdguardTeam/AdGuardHome
tier: optional
+80
View File
@@ -0,0 +1,80 @@
app:
id: archipelago-source
name: GitWorkshop
version: 0.4.0
upstream:
kind: github
repo: DanConwayDev/gitworkshop
description: >-
Get Archipelago's source, clone it with ngit, and contribute issues,
patches, and reviews over Nostr using the upstream GitWorkshop client.
category: development
container:
build:
context: /opt/archipelago/docker/archipelago-source
dockerfile: Dockerfile
tag: localhost/archipelago-source:local
resources:
cpu_limit: 1
memory_limit: 64Mi
disk_limit: 64Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: host
ports:
- host: 8337
container: 8337
protocol: tcp
bind: 127.0.0.1
auth: gated
session_passthrough: true
volumes:
- type: tmpfs
target: /tmp
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8337
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: GitWorkshop
description: NIP-34 repository browser, issues, pull requests, and review
type: ui
port: 8337
protocol: http
path: /
metadata:
# Versioned filename deliberately invalidates dashboard/browser icon caches
# when the Source prototype is replaced by the upstream GitWorkshop mark.
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
author: GitWorkshop contributors
repo: https://github.com/DanConwayDev/gitworkshop
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
tier: optional
launch:
# GitWorkshop is top-level in Companion's native in-app WebView. Its
# injected NIP-07 provider creates the authenticated dashboard-origin
# signer broker itself, so no dashboard parent frame is required.
requires_host_frame: false
features:
- NIP-34 repository discovery and browsing
- Bandwidth-efficient Git explorer over GRASP
- Nostr issues, pull requests, and code review
- NIP-07 extension and NIP-46 remote-signer support
- Archipelago node identity through explicit signing consent
+10 -2
View File
@@ -16,11 +16,13 @@ app:
pull_policy: if-not-present
dependencies:
- storage: 500Mi
# Source history, LFS objects, release artifacts and OCI layers all share
# this persistent store. 500Mi was only suitable for an empty demo node.
- storage: 50Gi
resources:
memory_limit: 256Mi
disk_limit: 500Mi
disk_limit: 50Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
@@ -66,6 +68,12 @@ app:
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
- GITEA__repository_0x2Erelease__MAX_FILES=20
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
+8 -8
View File
@@ -19,14 +19,15 @@ app:
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [api]
# The JWT signing secret is owned here (no backend container owns it); the
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
# only consumed here. ensure_generated_secrets no-ops when a file already
# exists, so live values on .228 are preserved (postgres pw is fixed at
# PGDATA init — regenerating would lock the API out).
# The JWT signing secret and stable envelope-encryption root are owned here;
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
# and only consumed here. Existing nodes migrate the legacy AES value into
# the secret file once, while fresh nodes receive a unique per-node value.
generated_secrets:
- name: indeedhub-jwt
kind: hex32
- name: indeedhub-aes-master
kind: hex16
secret_env:
- key: DATABASE_PASSWORD
secret_file: indeedhub-db-password
@@ -34,6 +35,8 @@ app:
secret_file: indeedhub-minio-password
- key: NOSTR_JWT_SECRET
secret_file: indeedhub-jwt
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-postgres
@@ -67,9 +70,6 @@ app:
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- S3_PUBLIC_BUCKET_URL=/storage
- NOSTR_JWT_EXPIRES_IN=7d
# Fixed across the fleet (envelope-encryption master key baked by the legacy
# installer); not node-specific, so a plain env literal, not a secret.
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
- ENVIRONMENT=production
health_check:
+2 -1
View File
@@ -22,6 +22,8 @@ app:
secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-api
@@ -51,4 +53,3 @@ app:
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- ENVIRONMENT=production
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
+3
View File
@@ -69,7 +69,10 @@ app:
- copy_from_host:
src: "web-ui/nostr-provider.js"
dest: "/usr/share/nginx/html/nostr-provider.js"
- exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = \/sw.js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"]
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf"]
- exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.11-alpha"
version = "1.8.15-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.11-alpha"
version = "1.8.15-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
webpki = { package = "rustls-webpki", version = "0.101" }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake).
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
# Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3"
@@ -269,6 +269,8 @@ impl RpcHandler {
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
@@ -55,6 +55,10 @@ impl RpcHandler {
"did": id.did,
"created_at": id.created_at,
"is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"profile": id.profile,
+12 -1
View File
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be",
"cannot",
"Password",
// auth.changePassword verifies the existing node password before it
// writes either the web hash or the optional Linux/SSH password. This
// is safe, actionable validation text; masking it as an internal
// failure sent operators to the server logs for a simple typo.
"Current password is incorrect",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
@@ -242,6 +247,12 @@ mod sanitize_tests {
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn change_password_rejection_reaches_the_operator() {
let msg = "Current password is incorrect";
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn tor_unavailable_precondition_passes_through() {
let msg = "Tor address not available. Tor may not be running.";
@@ -306,7 +317,7 @@ mod sanitize_tests {
/// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state).
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
+118 -19
View File
@@ -34,6 +34,7 @@ mod nostr;
mod onboarding_gate;
mod openwrt;
mod package;
pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
// never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
};
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Browser apps run on dedicated high ports and can share the authenticated
/// node cookie. Nostr signing must therefore be callable by the dashboard
/// bridge (ports 80/443), not directly by an iframe that could bypass its
/// consent dialog. Requests without Origin remain available to authenticated
/// local CLI/integration clients. Development permits loopback origins.
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
return true;
};
let Ok(url) = reqwest::Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return false;
}
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
return true;
}
matches!(url.port_or_known_default(), Some(80 | 443))
}
/// Read-only authenticated methods may skip CSRF, but they must still exist in
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
/// session probe, so keeping the policy in one testable function protects that
/// cross-origin app-gate bootstrap contract.
fn csrf_exempt_method(method: &str) -> bool {
matches!(
method,
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-hostname"
)
}
/// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries.
@@ -291,6 +333,18 @@ impl RpcHandler {
debug!("RPC method: {}", rpc_req.method);
if matches!(
rpc_req.method.as_str(),
"node.nostr-sign" | "identity.nostr-sign"
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
{
return Ok(self.error_response(
403,
"Nostr signing from app origins requires the dashboard consent bridge",
StatusCode::FORBIDDEN,
));
}
// Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
let mut new_session_cookies: Option<(String, String)> = None;
@@ -340,21 +394,7 @@ impl RpcHandler {
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(
rpc_req.method.as_str(),
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-version"
);
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts
.headers
@@ -735,3 +775,62 @@ impl RpcHandler {
);
}
}
#[cfg(test)]
mod nostr_signing_origin_tests {
use super::*;
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
fn headers(origin: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
headers
}
#[test]
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
assert!(nostr_signing_origin_allowed(&headers(None), false));
assert!(nostr_signing_origin_allowed(
&headers(Some("https://node.local")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://192.0.2.10")),
false
));
}
#[test]
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:8337")),
false
));
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:7778")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://localhost:5173")),
true
));
}
}
#[cfg(test)]
mod session_probe_contract_tests {
use super::*;
#[test]
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
const PROBE: &str = "system.get-hostname";
const DISPATCHER: &str = include_str!("dispatcher.rs");
assert!(csrf_exempt_method(PROBE));
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
}
}
+31 -1
View File
@@ -150,6 +150,7 @@ impl RpcHandler {
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
@@ -199,10 +200,15 @@ impl RpcHandler {
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }`
/// "mint_url": "<optional override>",
/// "payout_address": "<optional Lightning address>" }`
///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint.
///
/// `payout_address` sets the "owner" identity's Lightning address for
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
/// Omitted or blank leaves whatever's already on the router untouched.
pub(super) async fn handle_openwrt_provision_tollgate(
&self,
params: Option<serde_json::Value>,
@@ -240,12 +246,35 @@ impl RpcHandler {
.unwrap_or_default();
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
// mint URL against this value with an exact string compare, and
// Cashu wallets (Minibits included) encode mint URLs without a
// trailing slash. A stray slash here means every otherwise-valid
// token gets rejected as "untrusted mint" — confirmed live against
// archy-x250-pa3 2026-09-07 with a manually-entered
// "https://mint.minibits.cash/Bitcoin/".
let mint_url = p
.get("mint_url")
.and_then(|v| v.as_str())
.unwrap_or(&default_mint_url)
.trim_end_matches('/')
.to_string();
// `None` (not sent, or sent blank) leaves whatever's already on the
// router untouched — see apply_payout_identity's doc comment for why
// that matters (an upstream-default placeholder otherwise survives
// forever, since nothing else ever writes this field).
let payout_address = p
.get("payout_address")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(address) = payout_address.as_deref() {
tollgate::config::validate_payout_address(address)
.context("invalid TollGate payout address")?;
}
let config = TollGateConfig {
ssid: "archipelago".to_string(),
mint_url,
@@ -256,6 +285,7 @@ impl RpcHandler {
.unwrap_or(60_000),
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
payout_address,
};
// Blocking SSH session, and provision runs `opkg install` over it —
+187 -195
View File
@@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
pub(super) async fn patch_indeedhub_nostr_provider() {
fn patched_indeedhub_nginx_config(original: &str) -> String {
let mut conf = original
.lines()
.filter(|line| !line.contains("X-Frame-Options"))
.collect::<Vec<_>>()
.join("\n");
conf.push('\n');
if !conf.contains("location = /nostr-provider.js {") {
conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
}
if 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>';",
1,
);
}
conf = conf.replace(
"src=\"/nostr-provider.js\"",
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
);
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
conf.replace(
"proxy_set_header X-Forwarded-Prefix /api;",
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
)
}
pub(crate) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
// Frontend assets can change during a dashboard-only OTA while the
// IndeedHub container keeps running. Reconcile the injected provider on
// daemon startup as well as app install/start, but stay quiet when the app
// is not installed or is intentionally stopped.
let running = tokio::process::Command::new("podman")
.args(["inspect", "-f", "{{.State.Running}}", "indeedhub"])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
.await
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
.unwrap_or(false);
if !running {
return;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
// `podman exec` cannot always join a rootless container's delegated cgroup
// from the system service, while Podman 5's copier refuses to overwrite an
// existing regular file. Mount the rootless storage namespace instead;
// this replaces both files without entering the container's cgroup.
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let tmp_dir = format!("/tmp/indeedhub-nginx-patch-{}-{unique}", std::process::id());
let tmp_path = format!("{tmp_dir}/default.conf");
if tokio::fs::create_dir(&tmp_dir).await.is_err() {
tracing::warn!("IndeeHub signer reconciliation could not create its temporary directory");
return;
}
let mount_out = tokio::process::Command::new("podman")
.args(["unshare", "podman", "mount", "indeedhub"])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
let container_root = mount_out
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|path| {
std::path::Path::new(path).is_absolute()
&& path.contains("/containers/storage/overlay/")
&& path.ends_with("/merged")
});
let Some(container_root) = container_root else {
let _ = tokio::fs::remove_dir(&tmp_dir).await;
tracing::warn!("IndeeHub signer reconciliation could not mount rootless storage");
return;
};
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
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_copied = tokio::fs::metadata(provider_src).await.is_ok()
&& tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
provider_src,
&provider_dest,
])
.output()
.await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
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\"></script></head>';",
1,
)
} else {
conf
};
let copy_out = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await;
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
let mut config_copied = false;
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);
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
config_copied = tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
&tmp_path,
&format!("{container_root}/etc/nginx/conf.d/default.conf"),
])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if config_copied {
let _ = tokio::fs::remove_file(&tmp_path).await;
config_copied = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
&& tokio::fs::read_to_string(&tmp_path)
.await
.map(|actual| actual == conf)
.unwrap_or(false);
}
} else if conf == original
&& conf.contains("location = /nostr-provider.js {")
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
{
config_copied = true;
}
}
}
}
let _ = tokio::fs::remove_file(&tmp_path).await;
let _ = tokio::fs::remove_dir(&tmp_dir).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.args(["unshare", "podman", "unmount", "indeedhub"])
.output()
.await;
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.args(["kill", "--signal", "HUP", "indeedhub"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
Ok(o) if o.status.success() && provider_copied && config_copied => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
"IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}",
provider_copied,
config_copied,
String::from_utf8_lossy(&o.stderr)
);
}
@@ -1621,124 +1689,10 @@ autopilot.active=false\n",
}
}
// IndeeHub: inject nostr-provider.js and patch container nginx for NIP-07 signing
// IndeeHub: inject the current consent-gated provider and make it work
// in both the dashboard frame and a direct browser tab.
if package_id == "indeedhub" {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 1. Remove X-Frame-Options so iframe embedding works
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
// 2. Copy nostr-provider.js into container
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
// 3. Add nostr-provider.js location block + sub_filter injection
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
// Read current nginx config from container
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
// Insert provider location block before the sw.js location
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
\x20 add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
\x20 expires off;\n\
\x20 }\n\n\
\x20 location = /sw.js {"
);
// Inject script tag into HTML via sub_filter
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
\x20 sub_filter_once on;\n\
\x20 sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
// Write patched config back into container
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
// 4. Fix X-Forwarded-Prefix for NIP-98 URL reconstruction in iframe context
let _ = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "sed", "-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf"])
.output()
.await;
// 5. Reload nginx to apply changes
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
String::from_utf8_lossy(&o.stderr)
);
}
Err(e) => {
tracing::warn!("IndeeHub nginx reload error: {}", e);
}
}
patch_indeedhub_nostr_provider().await;
}
// Gitea: keep it on its native host port (3001). The UI opens Gitea
@@ -2790,6 +2744,11 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
| "gitea"
| "portainer"
| "meshtastic"
// Build-backed user-facing app. Route it through the production
// orchestrator so a fresh node builds its bundled image instead
// of treating localhost/archipelago-source:local as a registry
// image in the legacy installer.
| "archipelago-source"
)
}
@@ -2801,11 +2760,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
#[cfg(test)]
mod tests {
use super::{
orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
uses_orchestrator_install_flow,
orchestrator_install_app_id, parse_setup_token, patched_indeedhub_nginx_config,
should_try_orchestrator_install, uses_orchestrator_install_flow,
};
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
#[test]
fn indeedhub_nginx_patch_is_complete_and_idempotent() {
let original = r#"server {
add_header X-Frame-Options SAMEORIGIN;
location = /sw.js {
expires off;
}
location /api/ {
proxy_set_header X-Forwarded-Prefix /api;
}
location / {
try_files $uri $uri/ /index.html;
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
}
"#;
let patched = patched_indeedhub_nginx_config(original);
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("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
assert_eq!(patched_indeedhub_nginx_config(&patched), 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"));
assert!(!migrated.contains("tab-signer-v3"));
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
}
#[test]
fn orchestrator_install_allowlist_includes_ported_backends() {
for app in [
@@ -2837,6 +2828,7 @@ mod tests {
"gitea",
"portainer",
"meshtastic",
"archipelago-source",
] {
assert!(uses_orchestrator_install_flow(app));
assert!(should_try_orchestrator_install(app, true));
@@ -4,6 +4,7 @@ mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use install::patch_indeedhub_nostr_provider;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
+27 -2
View File
@@ -1559,6 +1559,31 @@ impl RpcHandler {
self.set_install_progress("indeedhub", n_images, n_images)
.await;
// The retired installer injected one fleet-wide AES root directly in
// the API/worker environment. Detect those consumers before removing
// anything, then persist the legacy value exactly once so an upgrade
// cannot orphan encrypted data. A genuinely fresh fallback install
// receives a random per-node root instead.
let mut had_existing_crypto_consumer = false;
for name in [
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
] {
let status =
podman_stack_status(&["container", "exists", name], PODMAN_STACK_PROBE_TIMEOUT)
.await?;
had_existing_crypto_consumer |= status.success();
}
let secrets_dir = self.config.data_dir.join("secrets");
crate::container::secrets::ensure_indeedhub_aes_master_secret(
&secrets_dir,
had_existing_crypto_consumer,
)
.context("preparing IndeedHub encryption root")?;
let aes_master = crate::container::secrets::indeedhub_aes_master_secret(&secrets_dir)?;
// Remove any leftover containers from a previous partial install (or
// from the first-boot frontend stub that used to race the installer).
// Without this, `podman run --name indeedhub` fails on name conflict
@@ -1759,7 +1784,7 @@ impl RpcHandler {
"-e".to_string(),
"NOSTR_JWT_EXPIRES_IN=7d".to_string(),
"-e".to_string(),
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
format!("AES_MASTER_SECRET={aes_master}"),
"-e".to_string(),
"ENVIRONMENT=production".to_string(),
format!("{registry}/indeedhub-api:1.0.0"),
@@ -1810,7 +1835,7 @@ impl RpcHandler {
"-e".to_string(),
"ENVIRONMENT=production".to_string(),
"-e".to_string(),
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
format!("AES_MASTER_SECRET={aes_master}"),
format!("{registry}/indeedhub-ffmpeg:1.0.0"),
],
&tmp_env,
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod known_service_tests {
use super::{is_protocol_service, known_service_port};
#[test]
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
// here means auto-enrollment silently skips it (known_service_port
// returns 0) and, separately, regenerate_torrc falls back to the
// web-app HiddenServicePort-80 default instead of forwarding 8333
// straight through.
assert_eq!(known_service_port("bitcoin-core"), 8333);
assert!(is_protocol_service("bitcoin-core"));
}
}
#[cfg(test)]
mod torrc_tests {
use super::app_hidden_service_port_line;
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!(
name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
)
}
+27
View File
@@ -421,6 +421,33 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
/// ecash wallet's own seed. Registers the profile on first use; safe to call
/// on every open of the Cashu receive screen (it is idempotent).
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
}
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
/// (0 when nothing was waiting), so the UI can refresh its balance.
/// `failed_count` is non-zero when a payment was fetched (and so already
/// consumed server-side) but couldn't be redeemed yet — it stays queued
/// and is retried automatically, but the UI should tell the operator
/// rather than let it be a silent, unbounded wait.
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
Ok(serde_json::json!({
"claimed_count": outcome.claimed_count,
"received_sats": outcome.received_sats,
"failed_count": outcome.failed_count,
"receipt_id": outcome.receipt_id,
"receipt_sats": outcome.receipt_sats,
"receipt_at": outcome.receipt_at,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
+289 -38
View File
@@ -148,9 +148,16 @@ impl AppGate {
let app = live.as_ref().unwrap_or(app);
let path = req.uri().path().to_string();
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
// sees the URI. Carry that trusted proxy mount into the challenge's
// form/assets and its post-login redirect so the browser stays inside
// the mounted app instead of posting to the dashboard root.
let mount_prefix = forwarded_mount_prefix(req.headers());
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
return self.handle_gate_action(req, app, action, client_ip).await;
return self
.handle_gate_action(req, app, action, client_ip, &mount_prefix)
.await;
}
// A browser fetches a few subresources WITHOUT credentials by
@@ -188,10 +195,25 @@ impl AppGate {
return proxy_to_app(req, app, false).await;
}
// Capture the platform session before the request is moved into the
// upstream proxy. Older app-gate sessions (issued before the paired
// CSRF-cookie fix) can then repair themselves on the very next app
// response, before the app's provider creates its signer iframe.
let session_for_csrf = crate::session::extract_session_cookie(req.headers());
let needs_csrf_cookie = cookie_value(req.headers(), "csrf_token").is_none();
match self.authorize(req.headers(), &app.app_id).await {
// The credential was a cookie (or none was needed): the
// Authorization header, if any, belongs to the app. Forward it.
Authorization::Allow => proxy_to_app(req, app, false).await,
Authorization::Allow => {
let mut response = proxy_to_app(req, app, false).await;
if needs_csrf_cookie {
if let Some(token) = session_for_csrf {
set_csrf_cookie(&mut response, &token).await;
}
}
response
}
// The credential WAS the Authorization header, and it was ours.
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
// 401 rather than a redirect: a redirect to a login page is
@@ -199,7 +221,9 @@ impl AppGate {
// clients would follow it and parse HTML as if it were their API
// response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body.
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
Authorization::Challenge => {
login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix)
}
}
}
@@ -229,6 +253,7 @@ impl AppGate {
app: &GatedPort,
action: &str,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
// Assets are GET and pre-auth by nature: the login page cannot
// render its own background or logo without them.
@@ -236,7 +261,7 @@ impl AppGate {
return self.serve_asset(name);
}
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
return login_page(app, None, StatusCode::OK, mount_prefix);
}
// Captured before the body is consumed. The pending-2FA session
@@ -253,17 +278,28 @@ impl AppGate {
app,
Some("Too many attempts. Wait a minute and try again."),
StatusCode::TOO_MANY_REQUESTS,
mount_prefix,
);
}
let form = match read_form(req).await {
Some(form) => form,
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
None => {
return login_page(
app,
Some("Malformed request."),
StatusCode::BAD_REQUEST,
mount_prefix,
)
}
};
match action {
"login" => self.do_login(app, &form, client_ip).await,
"totp" => self.do_totp(app, &form, pending, client_ip).await,
"login" => self.do_login(app, &form, client_ip, mount_prefix).await,
"totp" => {
self.do_totp(app, &form, pending, client_ip, mount_prefix)
.await
}
_ => not_found(),
}
}
@@ -288,14 +324,25 @@ impl AppGate {
.expect("asset response builds")
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
async fn do_login(
&self,
app: &GatedPort,
form: &Form,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
match self.auth.verify_password(&password).await {
Ok(true) => {}
_ => {
self.limiter.record_failure(client_ip).await;
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
return login_page(
app,
Some("Incorrect password."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
}
}
@@ -307,8 +354,8 @@ impl AppGate {
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
let pending = self.sessions.create_pending(secret).await;
let mut resp = totp_page(app, None, StatusCode::OK);
set_session_cookie(&mut resp, &pending);
let mut resp = totp_page(app, None, StatusCode::OK, mount_prefix);
set_session_cookie(&mut resp, &pending).await;
return resp;
}
}
@@ -319,12 +366,13 @@ impl AppGate {
app,
Some("Two-factor data could not be read. Sign in from the dashboard."),
StatusCode::INTERNAL_SERVER_ERROR,
mount_prefix,
);
}
let token = self.sessions.create().await;
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &token);
let mut resp = redirect_to_app(mount_prefix);
set_session_cookie(&mut resp, &token).await;
resp
}
@@ -334,10 +382,16 @@ impl AppGate {
form: &Form,
pending: Option<String>,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
let code = field(form, "code").unwrap_or_default();
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
return login_page(
app,
Some("Session expired."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
};
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
@@ -345,6 +399,7 @@ impl AppGate {
app,
Some("Session expired. Start again."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
};
@@ -371,17 +426,27 @@ impl AppGate {
}
match self.sessions.upgrade_to_full(&pending).await {
Some(full) => {
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &full);
let mut resp = redirect_to_app(mount_prefix);
set_session_cookie(&mut resp, &full).await;
resp
}
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
None => login_page(
app,
Some("Session expired."),
StatusCode::UNAUTHORIZED,
mount_prefix,
),
}
}
_ => {
self.limiter.record_failure(client_ip).await;
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
set_session_cookie(&mut resp, &pending);
let mut resp = totp_page(
app,
Some("Incorrect code."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
set_session_cookie(&mut resp, &pending).await;
resp
}
}
@@ -634,7 +699,7 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
}
}
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
async fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
// on the same host — and equally why an app on a *different* host (its
@@ -644,12 +709,82 @@ fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
// The dashboard RPC layer requires a readable CSRF cookie as well as the
// HttpOnly session cookie. An app-gate login is a complete node login, so
// it must establish the same pair as auth.login; otherwise a fresh browser
// can open the signer broker but every identity/signing RPC is rejected
// with `has_session=true, has_header=false`.
set_csrf_cookie(resp, token).await;
}
fn redirect_to_app() -> Response<Body> {
async fn set_csrf_cookie(resp: &mut Response<Body>, token: &str) {
let csrf = crate::api::rpc::derive_csrf_token(token).await;
if let Ok(value) =
header::HeaderValue::from_str(&format!("csrf_token={csrf}; SameSite=Lax; Path=/"))
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
let prefix = format!("{name}=");
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.map(str::trim)
.find_map(|pair| pair.strip_prefix(&prefix))
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
/// Validate the mount supplied by the node's own nginx proxy.
///
/// Treat this as untrusted input even though our canonical proxy sets it: a
/// client can reach an app-gate port directly and forge request headers. Only
/// a short absolute path made from ordinary URL-path characters is accepted;
/// protocol-relative URLs, dot segments, escaping and query/fragment syntax
/// all fall back to the direct-port root.
fn forwarded_mount_prefix(headers: &HeaderMap) -> String {
let Some(raw) = headers
.get("x-forwarded-prefix")
.and_then(|value| value.to_str().ok())
else {
return String::new();
};
let value = raw.trim_end_matches('/');
if value.is_empty()
|| value.len() > 256
|| !value.starts_with('/')
|| value.starts_with("//")
|| value
.bytes()
.any(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')))
|| value
.split('/')
.skip(1)
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return String::new();
}
value.to_owned()
}
fn gate_url(mount_prefix: &str, action: &str) -> String {
format!("{mount_prefix}{GATE_PREFIX}{action}")
}
fn redirect_to_app(mount_prefix: &str) -> Response<Body> {
let location = if mount_prefix.is_empty() {
"/".to_owned()
} else {
format!("{mount_prefix}/")
};
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.header(header::LOCATION, location)
.body(Body::empty())
.expect("static response builds")
}
@@ -674,7 +809,13 @@ dashboard and check {name} under My Apps.</p>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
);
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
let mut resp = page(
"App not responding",
app,
&body,
StatusCode::BAD_GATEWAY,
"",
);
// Header-based refresh, not <meta> or script: page()'s CSP allows no
// script, and the header keeps the retry out of the document entirely.
resp.headers_mut()
@@ -707,7 +848,7 @@ fn esc(s: &str) -> String {
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
/// One stacked layer per background, each delayed so they cross-fade in turn.
fn background_layers() -> String {
fn background_layers(mount_prefix: &str) -> String {
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
LOGIN_BACKGROUNDS
.iter()
@@ -715,7 +856,7 @@ fn background_layers() -> String {
.map(|(i, name)| {
format!(
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
delay = i as u32 * step,
)
})
@@ -938,7 +1079,13 @@ fn base64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
fn page(
title: &str,
app: &GatedPort,
body: &str,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let html = format!(
r#"<!doctype html>
<html lang="en"><head>
@@ -1055,7 +1202,7 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
app_name = esc(&app.app_name),
body = body,
submit_feedback = SUBMIT_FEEDBACK_JS,
backgrounds = background_layers(),
backgrounds = background_layers(mount_prefix),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
@@ -1092,7 +1239,12 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
/// The challenge. Names and pictures the app being opened, so the visitor can
/// confirm what they are authenticating to rather than being asked for a
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
fn login_page(
app: &GatedPort,
error: Option<&str>,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let body = format!(
r#"{logo}
{icon}
@@ -1110,14 +1262,19 @@ fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respo
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
);
page("Sign in", app, &body, status)
page("Sign in", app, &body, status, mount_prefix)
}
/// Second factor. Reached only after the password verified, and the session
/// backing it cannot authorise anything until this completes.
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
fn totp_page(
app: &GatedPort,
error: Option<&str>,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Two-factor code</h1>
@@ -1133,9 +1290,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
);
page("Two-factor", app, &body, status)
page("Two-factor", app, &body, status, mount_prefix)
}
#[cfg(test)]
@@ -1207,9 +1364,35 @@ mod tests {
assert_eq!(bearer_token(&headers), None);
}
#[test]
fn forwarded_mount_prefix_accepts_only_a_safe_absolute_path() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-prefix",
"/app/archipelago-source/".parse().unwrap(),
);
assert_eq!(forwarded_mount_prefix(&headers), "/app/archipelago-source");
for unsafe_value in [
"//other.example/app",
"/app/../admin",
"/app//source",
"/app/source?next=//other.example",
"https://other.example/app",
"/app/%2e%2e/admin",
] {
headers.insert("x-forwarded-prefix", unsafe_value.parse().unwrap());
assert_eq!(
forwarded_mount_prefix(&headers),
"",
"accepted {unsafe_value}"
);
}
}
#[tokio::test]
async fn login_page_names_the_app() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
@@ -1222,7 +1405,7 @@ mod tests {
async fn page_escapes_app_names() {
let mut app = app();
app.app_name = r#"<script>alert(1)</script>"#.to_string();
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED, "");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<script>alert"));
@@ -1235,6 +1418,7 @@ mod tests {
&app(),
Some("<img src=x onerror=1>"),
StatusCode::UNAUTHORIZED,
"",
);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
@@ -1293,7 +1477,7 @@ mod tests {
#[test]
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert!(
!resp.headers().contains_key("X-Frame-Options"),
@@ -1329,7 +1513,7 @@ mod tests {
/// never 404 at all.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body).to_string();
assert_eq!(
@@ -1345,13 +1529,32 @@ mod tests {
}
}
#[tokio::test]
async fn mounted_login_keeps_forms_assets_and_redirect_inside_the_app() {
let mount = "/app/archipelago-source";
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, mount);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains(r#"action="/app/archipelago-source/__archipelago-gate/login""#));
for name in LOGIN_BACKGROUNDS {
assert!(html.contains(&format!("/app/archipelago-source{GATE_PREFIX}asset/{name}")));
}
let redirect = redirect_to_app(mount);
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
assert_eq!(
redirect.headers()[header::LOCATION],
"/app/archipelago-source/"
);
}
/// The only script the challenge pages may run is the submit-feedback
/// snippet, admitted by hash. The page must carry exactly that script,
/// and the CSP must name its hash — anything injected has a different
/// hash and stays inert.
#[tokio::test]
async fn submit_feedback_script_is_present_and_hash_pinned() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
let csp = resp.headers()["Content-Security-Policy"]
.to_str()
.unwrap()
@@ -1455,6 +1658,54 @@ mod tests {
assert!(headers.get(header::COOKIE).is_none());
}
#[test]
fn cookie_value_finds_only_a_nonempty_named_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"app_session=keep; csrf_token=csrf123; empty="
.parse()
.unwrap(),
);
assert_eq!(
cookie_value(&headers, "csrf_token"),
Some("csrf123".to_string())
);
assert_eq!(cookie_value(&headers, "session"), None);
assert_eq!(cookie_value(&headers, "empty"), None);
}
/// An app-gate login must be equivalent to a dashboard login. The session
/// cookie alone can load the broker route, but every identity/signing RPC
/// also needs the matching readable CSRF cookie.
#[tokio::test]
async fn app_gate_login_establishes_the_dashboard_csrf_cookie() {
let token = "app-gate-session-token";
let mut resp = redirect_to_app("");
set_session_cookie(&mut resp, token).await;
let cookies: Vec<_> = resp
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect();
let expected_csrf = crate::api::rpc::derive_csrf_token(token).await;
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with(&format!("session={token};"))));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with(&format!("csrf_token={expected_csrf};"))));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with("session=") && cookie.contains("HttpOnly")));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("HttpOnly")));
}
/// The regression that killed every Nostr login on 2026-08-06.
///
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
+135 -2
View File
@@ -26,7 +26,7 @@ const DOCTOR_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
const DOCTOR_SH_PATH: &str = "/opt/archipelago/scripts/container-doctor.sh";
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
@@ -85,6 +85,15 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n";
const NGINX_SOURCE_PROXY_BLOCK_SNIPPET: &str = "# GitWorkshop follows the dashboard origin; the app gate keeps the route\n# session-authenticated before it reaches the loopback-only container.\nlocation /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n}\n";
/// The normal dashboard sends X-Frame-Options SAMEORIGIN. This one document
/// must be frameable by an app on another port of the same node so tabs and
/// companion WebViews can use the same authenticated signer UI.
const NGINX_NOSTR_SIGNER_BLOCK: &str = " # Dashboard-origin Nostr signer for tab/WebView apps.\n location = /nostr-signer {\n try_files /index.html =404;\n add_header Cache-Control \"no-store\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n add_header Referrer-Policy \"no-referrer\" always;\n add_header Content-Security-Policy \"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' http://$host:* https://$host:*; base-uri 'none'; form-action 'none';\" always;\n }\n\n";
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
@@ -1231,7 +1240,7 @@ async fn run() -> Result<bool> {
let mut changed = false;
// 1. Script — lives in archipelago's home dir, user-writable.
// 1. Script — lives in the canonical OTA runtime scripts directory.
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
.await
@@ -1580,6 +1589,62 @@ fn heal_stale_web_search_block(content: &str) -> Option<String> {
))
}
fn heal_missing_source_proxy(content: &str) -> Option<String> {
if content.contains("location /app/archipelago-source/") {
return None;
}
let indented_anchor = " location /app/gitea/ {";
if content.contains(indented_anchor) {
return Some(content.replace(
indented_anchor,
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK, indented_anchor),
));
}
let snippet_anchor = "location /app/gitea/ {";
content.contains(snippet_anchor).then(|| {
content.replace(
snippet_anchor,
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK_SNIPPET, snippet_anchor),
)
})
}
/// Older same-origin GitWorkshop blocks stripped the app mount but did not
/// tell the app gate what was stripped. Its challenge therefore posted to
/// `/__archipelago-gate/login` on the dashboard and nginx returned 405. Add
/// the mount header to every canonical source block (HTTP and HTTPS snippet).
fn heal_source_forwarded_prefix(content: &str) -> Option<String> {
if !content.contains("proxy_pass http://127.0.0.2:8337/;") {
return None;
}
let mut healed = content.to_owned();
for indent in [" ", " "] {
let old = format!(
"proxy_pass http://127.0.0.2:8337/;\n{indent}proxy_http_version 1.1;\n{indent}proxy_set_header Host $http_host;\n{indent}proxy_set_header Cookie $http_cookie;\n{indent}proxy_set_header X-Real-IP $remote_addr;\n{indent}proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n{indent}proxy_set_header X-Forwarded-Proto $scheme;\n{indent}proxy_hide_header X-Frame-Options;"
);
let new = old.replace(
&format!("\n{indent}proxy_hide_header X-Frame-Options;"),
&format!(
"\n{indent}proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n{indent}proxy_hide_header X-Frame-Options;"
),
);
healed = healed.replace(&old, &new);
}
(healed != content).then_some(healed)
}
fn heal_missing_nostr_signer(content: &str) -> Option<String> {
if content.contains("location = /nostr-signer") {
return None;
}
// The anchor occurs once in each complete HTTP/HTTPS dashboard server and
// does not occur in the separate app-proxy snippet.
let anchor = " location /aiui/ {";
content
.contains(anchor)
.then(|| content.replace(anchor, &format!("{}{}", NGINX_NOSTR_SIGNER_BLOCK, anchor)))
}
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
@@ -1610,6 +1675,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
let missing_v6_https =
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
let stale_web_search = heal_stale_web_search_block(&content).is_some();
let missing_source_proxy = heal_missing_source_proxy(&content).is_some();
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
@@ -1620,6 +1688,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !missing_v6_http
&& !missing_v6_https
&& !stale_web_search
&& !missing_source_proxy
&& !missing_source_prefix
&& !missing_nostr_signer
{
return Ok(false);
}
@@ -1629,6 +1700,15 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
if let Some(p) = heal_stale_web_search_block(&patched) {
patched = p;
}
if let Some(p) = heal_missing_source_proxy(&patched) {
patched = p;
}
if let Some(p) = heal_source_forwarded_prefix(&patched) {
patched = p;
}
if let Some(p) = heal_missing_nostr_signer(&patched) {
patched = p;
}
if missing_v6_http {
patched = patched.replace(
@@ -1796,6 +1876,17 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
mod tests {
use super::*;
#[test]
fn doctor_service_uses_the_canonical_ota_script_path() {
let expected = format!("ExecStart={} --local", DOCTOR_SH_PATH);
assert!(DOCTOR_SERVICE.lines().any(|line| line == expected));
assert_eq!(
DOCTOR_SH_PATH,
"/opt/archipelago/scripts/container-doctor.sh"
);
assert!(!DOCTOR_SERVICE.contains("/home/archipelago/archy/"));
}
#[test]
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
let outcome = PodmanHealOutcome::Unhealthy;
@@ -1817,6 +1908,48 @@ mod tests {
);
}
#[test]
fn source_proxy_uses_same_origin_through_authenticated_app_gate() {
let main = "server {\n location /app/gitea/ {\n }\n}\nserver {\n location /app/gitea/ {\n }\n}";
let healed = heal_missing_source_proxy(main).expect("source proxy must be added");
assert_eq!(
healed.matches("location /app/archipelago-source/").count(),
2
);
assert!(healed.contains("proxy_pass http://127.0.0.2:8337/;"));
assert!(healed.contains("proxy_set_header Cookie $http_cookie;"));
assert!(healed.contains("proxy_set_header X-Forwarded-Prefix /app/archipelago-source;"));
assert!(heal_missing_source_proxy(&healed).is_none());
let snippet = "location /app/gitea/ {\n}";
let healed = heal_missing_source_proxy(snippet).expect("snippet must be patched");
assert!(healed.starts_with("# GitWorkshop follows the dashboard origin"));
}
#[test]
fn existing_source_proxy_gets_the_forwarded_mount_once() {
let stale = "location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_hide_header X-Frame-Options;\n}";
let healed = heal_source_forwarded_prefix(stale).expect("mount header must be added");
assert_eq!(
healed
.matches("X-Forwarded-Prefix /app/archipelago-source")
.count(),
1
);
assert!(heal_source_forwarded_prefix(&healed).is_none());
}
#[test]
fn nostr_signer_is_added_to_each_dashboard_server_only_once() {
let main =
"server {\n location /aiui/ {\n }\n}\nserver {\n location /aiui/ {\n }\n}";
let healed = heal_missing_nostr_signer(main).expect("signer route must be added");
assert_eq!(healed.matches("location = /nostr-signer").count(), 2);
assert!(healed.contains("frame-ancestors 'self' http://$host:* https://$host:*"));
assert!(heal_missing_nostr_signer(&healed).is_none());
assert!(heal_missing_nostr_signer("location /app/gitea/ {}\n").is_none());
}
/// The exact ExecStart framework-pt shipped with must parse, and the
/// rewrite must preserve its listen port and forward target.
#[test]
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
///
/// This must match what enrollment actually names the hidden service dir
/// with — both the install-time auto-enroll (`install.rs`) and the manual
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
/// gets its own identity arm rather than folding into the "bitcoin" alias:
/// aliasing it here without also canonicalizing the write side would point
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id {
"archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"),
@@ -906,6 +916,28 @@ mod launch_url_port_tests {
}
}
#[cfg(test)]
mod tor_service_name_tests {
use super::tor_service_name;
#[test]
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
// Regression: enrollment (install.rs, tor.create-service) writes
// HiddenServiceDir/tor-hostnames entries using the raw package_id
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
// shared "bitcoin" name here would point reads at a directory
// enrollment never creates.
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
}
#[test]
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
}
}
#[cfg(test)]
mod extract_lan_address_tests {
use super::extract_lan_address;
@@ -183,7 +183,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
_ => None,
@@ -3591,6 +3591,54 @@ impl ProdContainerOrchestrator {
Ok(())
}
/// Materialise IndeedHub's AES root before the generic generated-secret
/// pass. Old installers injected one known value directly into the API and
/// worker environments, so an upgrade with either consumer still present
/// must persist that value before container drift can recreate them. With
/// no existing consumer this is a fresh install and receives random bytes.
async fn ensure_indeedhub_aes_master(&self, manifest: &AppManifest) -> Result<()> {
if manifest.app.id != "indeedhub-api" {
return Ok(());
}
let secret_path = self
.secrets_dir
.join(crate::container::secrets::INDEEDHUB_AES_SECRET_NAME);
let preserve_legacy = if secret_path.exists() {
// The secret helper validates the existing file and, critically,
// refuses to replace a damaged encryption root.
false
} else {
let consumers = [
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
];
self.runtime
.list_containers()
.await
.context("detecting an existing IndeedHub encryption-key consumer")?
.iter()
.any(|container| {
let name = container.name.trim_start_matches('/');
consumers.contains(&name)
})
};
if crate::container::secrets::ensure_indeedhub_aes_master_secret(
&self.secrets_dir,
preserve_legacy,
)? {
tracing::info!(
app = "indeedhub-api",
path = %secret_path.display(),
"Persisted the legacy IndeedHub encryption root for upgrade compatibility"
);
}
Ok(())
}
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
// Idempotency guard: partitioning already ran on this instance.
// Re-running would re-taint against an environment that no longer
@@ -3599,6 +3647,11 @@ impl ProdContainerOrchestrator {
if !manifest.app.container.secret_env_refs.is_empty() {
return Ok(());
}
// IndeedHub's data-encryption root needs an upgrade-aware first pass:
// generic generation alone would replace the fleet-wide legacy value
// and make previously encrypted data unreadable.
self.ensure_indeedhub_aes_master(manifest).await?;
// Materialise any manifest-declared generated secrets before they're
// read below. This is the single chokepoint every install/reconcile
// path funnels through, so an app's secrets exist by the time its
@@ -5674,6 +5727,52 @@ app:
"app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n"
}
fn indeedhub_api_manifest_yaml() -> &'static str {
"app:\n id: indeedhub-api\n name: IndeedHub API\n version: 1.0.0\n container:\n image: x:1\n generated_secrets:\n - name: indeedhub-aes-master\n kind: hex16\n secret_env:\n - key: AES_MASTER_SECRET\n secret_file: indeedhub-aes-master\n"
}
#[tokio::test]
async fn existing_indeedhub_consumer_gets_migration_compatible_root() {
let rt = Arc::new(MockRuntime::default());
rt.set_state("indeedhub-api", ContainerState::Running);
let mut orch = orch_with(rt).await;
let tmp = tempfile::TempDir::new().unwrap();
orch.set_secrets_dir(tmp.path().to_path_buf());
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let resolved = manifest
.app
.container
.secret_env_refs
.iter()
.find(|entry| entry.env_key == "AES_MASTER_SECRET")
.unwrap();
assert_eq!(resolved.value.len(), 32);
assert!(tmp.path().join("indeedhub-aes-master").exists());
assert!(
crate::container::secrets::ensure_indeedhub_aes_master_secret(tmp.path(), true).is_ok(),
"the migrated file remains valid and stable"
);
}
#[tokio::test]
async fn fresh_indeedhub_install_gets_random_root() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt).await;
let tmp = tempfile::TempDir::new().unwrap();
orch.set_secrets_dir(tmp.path().to_path_buf());
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let first = crate::container::secrets::indeedhub_aes_master_secret(tmp.path()).unwrap();
let other = tempfile::TempDir::new().unwrap();
crate::container::secrets::ensure_indeedhub_aes_master_secret(other.path(), false).unwrap();
let second = crate::container::secrets::indeedhub_aes_master_secret(other.path()).unwrap();
assert_ne!(first, second, "fresh installs must receive per-node roots");
}
/// FED-07. Rotating a compromised credential leaves the RUNNING container
/// holding the old value, so the rotation must flag the app for recreate.
/// Without the flag the drift check skips it as restart-sensitive and the
+130
View File
@@ -140,6 +140,79 @@ fn random_base64(bytes: usize) -> String {
/// daemon read `fedimint-gateway-hash`).
pub const GATEWAY_HASH_SECRET_NAME: &str = "fedimint-gateway-hash";
/// Canonical filename for IndeedHub's envelope-encryption root. API and media
/// worker must receive the same stable value: changing it after data has been
/// encrypted can make that data unreadable.
pub const INDEEDHUB_AES_SECRET_NAME: &str = "indeedhub-aes-master";
/// The fleet-wide value used by the legacy IndeedHub installers. It remains
/// here only for the one-way migration of an already-installed stack: those
/// nodes must persist the value they have been using before the manifest
/// starts reading it from a file. Fresh installs must never receive it.
const KNOWN_LEGACY_INDEEDHUB_AES_MASTER: &str = "0123456789abcdef0123456789abcdef";
/// Ensure IndeedHub has a stable encryption root.
///
/// `preserve_legacy` is true only when an API/worker container already exists,
/// proving this is an upgrade from the installer that shipped the known legacy
/// value. In that case we persist that value once so recreating the containers
/// does not orphan encrypted data. A fresh installation gets 16 random bytes
/// encoded as 32 hex characters.
///
/// Unlike ordinary generated credentials, an existing-but-empty or unreadable
/// encryption root is never self-healed by rotation: replacement could destroy
/// access to data, so this fails loudly and leaves the file untouched.
/// Returns true only when the legacy migration value was written.
pub fn ensure_indeedhub_aes_master_secret(
secrets_dir: &Path,
preserve_legacy: bool,
) -> Result<bool> {
fs::create_dir_all(secrets_dir)
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
if path.exists() {
let value = fs::read_to_string(&path).with_context(|| {
format!(
"reading IndeedHub encryption root {} (refusing to replace it)",
path.display()
)
})?;
if value.trim().is_empty() {
anyhow::bail!(
"IndeedHub encryption root {} is empty; refusing to replace a potentially \
data-bearing key",
path.display()
);
}
return Ok(false);
}
if preserve_legacy {
write_secret(&path, KNOWN_LEGACY_INDEEDHUB_AES_MASTER)?;
return Ok(true);
}
let spec = GeneratedSecret {
name: INDEEDHUB_AES_SECRET_NAME.to_string(),
kind: SecretGenKind::Hex16,
};
ensure_one(secrets_dir, &spec)?;
Ok(false)
}
/// Read the stable IndeedHub encryption root after it has been materialised.
pub fn indeedhub_aes_master_secret(secrets_dir: &Path) -> Result<String> {
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
let value = fs::read_to_string(&path)
.with_context(|| format!("reading IndeedHub encryption root {}", path.display()))?;
let value = value.trim();
if value.is_empty() {
anyhow::bail!("IndeedHub encryption root {} is empty", path.display());
}
Ok(value.to_string())
}
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
@@ -356,6 +429,63 @@ mod tests {
);
}
#[test]
fn indeedhub_fresh_installs_get_distinct_per_node_encryption_roots() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
assert!(!ensure_indeedhub_aes_master_secret(dir_a.path(), false).unwrap());
assert!(!ensure_indeedhub_aes_master_secret(dir_b.path(), false).unwrap());
let value_a = indeedhub_aes_master_secret(dir_a.path()).unwrap();
let value_b = indeedhub_aes_master_secret(dir_b.path()).unwrap();
assert_eq!(value_a.len(), 32);
assert!(value_a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(value_a, KNOWN_LEGACY_INDEEDHUB_AES_MASTER);
assert_ne!(value_a, value_b, "fresh nodes must not share an AES root");
let mode = std::fs::metadata(dir_a.path().join(INDEEDHUB_AES_SECRET_NAME))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn indeedhub_existing_install_persists_legacy_root_once() {
let dir = tempfile::tempdir().unwrap();
assert!(ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
assert_eq!(
indeedhub_aes_master_secret(dir.path()).unwrap(),
KNOWN_LEGACY_INDEEDHUB_AES_MASTER
);
assert!(
!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap(),
"a second migration pass must be a no-op"
);
}
#[test]
fn indeedhub_existing_unique_root_is_never_rotated() {
let dir = tempfile::tempdir().unwrap();
ensure_indeedhub_aes_master_secret(dir.path(), false).unwrap();
let before = indeedhub_aes_master_secret(dir.path()).unwrap();
assert!(!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
assert_eq!(before, indeedhub_aes_master_secret(dir.path()).unwrap());
}
#[test]
fn indeedhub_empty_root_fails_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(INDEEDHUB_AES_SECRET_NAME);
std::fs::write(&path, "").unwrap();
let err = ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap_err();
assert!(err.to_string().contains("refusing to replace"));
assert_eq!(std::fs::read(&path).unwrap(), b"");
}
#[test]
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
let dir = tempfile::tempdir().unwrap();
+2 -2
View File
@@ -6,7 +6,7 @@
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 18091, 23000, 32838, 50002,
];
+5
View File
@@ -413,6 +413,11 @@ async fn main() -> Result<()> {
// delays server readiness; best-effort, warnings only.
tokio::spawn(bootstrap::ensure_doctor_installed());
// Dashboard-only updates can replace the NIP-07 provider without
// recreating a running IndeedHub container. Reconcile its injected copy on
// every daemon start so tab signing never remains pinned to an old asset.
tokio::spawn(api::rpc::patch_indeedhub_nostr_provider());
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
// only — effective next reboot; never restarts the running service.
+40
View File
@@ -22,6 +22,46 @@ use crate::wallet::ecash;
///
/// Returns the total sats swept in (0 if there was nothing to do, including
/// when no router is configured or it doesn't have TollGate installed).
///
/// # KNOWN BROKEN as of 2026-09-07 — do not "fix" by adding `--json` without
/// reading the rest of this comment first.
///
/// Confirmed live against archy-x250-pa3, two stacked bugs in the upstream
/// `tollgate` CLI, not in this function:
///
/// 1. **This call never actually drains anything.** `tollgate wallet drain
/// cashu` (no flags — what this function runs) prints an interactive
/// `Are you sure? (y/N)` confirmation and reads stdin for the answer.
/// `Router::run` executes over SSH with no PTY and empty stdin, so it
/// always reads EOF, defaults to "N", and prints "Operation cancelled." —
/// **with exit code 0**. The `drain_code != 0` check below can never catch
/// this, so every single tick silently falls through to "no `Token:`
/// lines found" → `Ok(0)`. No error, no log line (even at `warn!`), just
/// quiet total inaction, forever. This has presumably never swept a
/// single sat on any node.
///
/// 2. **The obvious fix is worse.** `tollgate --json wallet drain cashu`
/// *does* skip the confirmation prompt — but confirmed live: when the
/// wallet's internal per-mint registry holds more than one entry for what
/// is really the same mint (here: `https://mint.minibits.cash/Bitcoin` vs.
/// a stale `.../Bitcoin/` — leftover from before the trailing-slash
/// `mint_url` fix elsewhere in this codebase; `wallet.db` still had a
/// proof/registry entry keyed under the old slashed URL even after
/// `config.json` was corrected), the CLI appears to complete a real swap
/// against the *good* entry — spending and irreversibly consuming the
/// original proofs, per how Cashu swaps work — then hits the second,
/// empty, stale-keyed entry, reports the whole command as
/// `"success": false`, and **never prints or persists the resulting
/// token anywhere** (checked every location its own "will be saved to a
/// file" warning implies: `/etc/tollgate/ecash/`, `/root`, `/tmp`,
/// nothing). Balance went from 50 sats to 0 across that one call. The
/// funds are gone — there is no undo once a swap is submitted to the
/// mint.
///
/// Do not wire `--json` into this function until upstream fixes partial
/// per-mint failure handling in `drain cashu` to preserve/return whatever it
/// already successfully drained. Until then, the current silent-no-op
/// behavior, while useless, is at least safe.
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
let cfg = net_router::load_router_config(data_dir).await?;
if !cfg.configured {
+47
View File
@@ -207,7 +207,15 @@ impl CashuToken {
}
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
///
/// Trims surrounding whitespace first: a token can arrive with stray
/// leading/trailing whitespace from a clipboard paste, or (confirmed
/// live, 2026-09-08) from Minibits' own NIP-04 claim-DM content, which
/// has a trailing space after the base64 — none of the base64 alphabets
/// in `decode_token_base64` tolerate that, so an otherwise-valid token
/// would hard-fail with "Invalid base64" instead of parsing.
pub fn deserialize(token_str: &str) -> Result<Self> {
let token_str = token_str.trim();
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
return Self::deserialize_v4(payload);
}
@@ -508,6 +516,45 @@ mod tests {
assert_eq!(decoded.memo, Some("test token".to_string()));
}
/// Regression guard (2026-09-08): a real Minibits claim DM decrypted to
/// a cashuB token with a trailing space after the base64 payload, which
/// made every base64 alphabet in `decode_token_base64` reject it as
/// invalid — three real payments got stuck retrying forever with
/// "Invalid base64 in cashuB token" until `deserialize` started
/// trimming the whole string first. Whitespace can show up around a
/// token from more than one source (clipboard paste included), so this
/// covers cashuA too, and leading as well as trailing.
#[test]
fn deserialize_trims_stray_whitespace() {
let token = CashuToken {
token: vec![TokenEntry {
mint: "http://127.0.0.1:8175".to_string(),
proofs: vec![Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
.to_string(),
}],
}],
memo: None,
unit: Some("sat".to_string()),
};
let encoded = token.serialize().unwrap();
assert!(encoded.starts_with("cashuA"));
for wrapped in [
format!("{encoded} "),
format!(" {encoded}"),
format!(" {encoded}\n"),
format!("{encoded}\t"),
] {
let decoded = CashuToken::deserialize(&wrapped)
.unwrap_or_else(|e| panic!("failed on {wrapped:?}: {e}"));
assert_eq!(decoded.total_amount(), 8);
}
}
#[test]
fn test_total_amount_multi_proof() {
let token = CashuToken {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@ pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod minibits;
pub mod mint_client;
pub mod nut13;
pub mod profits;
+12
View File
@@ -137,6 +137,18 @@ impl EcashSeed {
self.mnemonic.words().map(|w| w.to_string()).collect()
}
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
pub fn phrase(&self) -> String {
self.mnemonic.to_string()
}
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
/// its `seedHash`, so the two wallets agree on wallet identity.
pub fn seed_bytes(&self) -> [u8; 64] {
self.seed
}
pub fn source(&self) -> SeedSource {
self.source
}
+3 -10
View File
@@ -1746,11 +1746,6 @@ app:
}
}
exempt.sort();
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
// same reason router's mDNS/SSDP and every p2p port is exempt; each
// carries its auth_rationale in the manifest.
//
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
@@ -1776,7 +1771,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
30,
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
@@ -1811,13 +1806,11 @@ app:
// by anonymous payers), and — since the v1.8.7 platform round — the
// three own-login consoles brought onto the manifest platform:
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
// (tailnet login on the web console), adguardhome 3000 (AGH admin
// accounts + first-run wizard). All enforce their own login, and an
// operator can re-gate any of them from Settings → Access control.
// (tailnet login on the web console). Both enforce their own login,
// and an operator can re-gate either from Settings → Access control.
assert_eq!(
open,
vec![
("adguardhome".to_string(), 3000u16),
("btcpay-server".to_string(), 23000u16),
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
+182 -13
View File
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool,
/// Operator's own Lightning address for the daemon's built-in payout
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
/// weight 0.79 in the upstream default). `None` leaves whatever is
/// already on the router untouched — which, on a router whose TollGate
/// wasn't provisioned through this project, is an unmodified upstream
/// placeholder nobody actually controls (confirmed live against
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
pub payout_address: Option<String>,
}
impl Default for TollGateConfig {
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
step_size_ms: 60_000,
min_steps: 1,
enabled: true,
payout_address: None,
}
}
}
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
/// effect on what the daemon advertises or accepts.
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply(
"tollgate",
&[
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
],
)?;
let step_size = cfg.step_size_ms.to_string();
let min_steps = cfg.min_steps.to_string();
let price_sats = cfg.price_sats.to_string();
let mut pairs = vec![
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", step_size.as_str()),
("tollgate.main.min_steps", min_steps.as_str()),
("tollgate.main.price_per_step", price_sats.as_str()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
];
// Status-display only (see doc comment above) — only written when the
// caller actually supplied one, so a reconfigure that doesn't touch
// payout leaves whatever's already there alone.
if let Some(addr) = &cfg.payout_address {
pairs.push(("tollgate.main.payout_address", addr));
}
router.uci_apply("tollgate", &pairs)?;
Ok(())
}
@@ -97,3 +114,155 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
.context("upload /etc/tollgate/config.json")?;
Ok(())
}
/// Set the operator's own payout Lightning address in
/// `/etc/tollgate/identities.json` — the "owner" entry under
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
/// the other entries there are revenue-share addresses for the upstream
/// project's own maintainers and must never be touched by this function).
///
/// No-op when `payout_address` is `None` — the UI only sends one when the
/// operator has actually filled the field in, so a reconfigure of price/mint
/// alone never overwrites this. Merges into whatever identities.json already
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
/// the merchant's own private key and must survive untouched); creates an
/// "owner" entry if none exists yet rather than erroring, since a router
/// whose TollGate wasn't provisioned through this project may have any
/// upstream-default shape here.
///
/// Must run before the daemon restart in `restart_services` — like
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
let Some(address) = payout_address else {
return Ok(());
};
validate_payout_address(address)?;
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
let mut doc = parse_identities(&existing)?;
merge_payout_identity(&mut doc, address)?;
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
router
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
.context("upload /etc/tollgate/identities.json")?;
Ok(())
}
fn parse_identities(existing: &str) -> Result<serde_json::Value> {
serde_json::from_str(existing.trim()).context(
"parse existing /etc/tollgate/identities.json; refusing to overwrite malformed identity data",
)
}
/// Reject malformed values before provisioning changes anything on the
/// router. A payout typo otherwise remains dormant until the threshold is
/// reached, when the operator discovers that settlement cannot resolve.
pub fn validate_payout_address(address: &str) -> Result<()> {
let (name, domain) = address
.split_once('@')
.context("Lightning address must look like name@example.com")?;
if name.is_empty()
|| domain.is_empty()
|| domain.contains('@')
|| address.chars().any(char::is_whitespace)
{
anyhow::bail!("Lightning address must look like name@example.com");
}
Ok(())
}
fn merge_payout_identity(doc: &mut serde_json::Value, address: &str) -> Result<()> {
let identities = doc
.as_object_mut()
.context("identities.json root is not a JSON object")?
.entry("public_identities")
.or_insert_with(|| serde_json::json!([]));
let identities = identities
.as_array_mut()
.context("identities.json public_identities is not an array")?;
match identities
.iter_mut()
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
{
Some(owner) => {
owner["lightning_address"] = serde_json::json!(address);
}
None => {
identities.push(serde_json::json!({
"name": "owner",
"pubkey": "not currently used",
"lightning_address": address,
}));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{merge_payout_identity, parse_identities, validate_payout_address};
#[test]
fn payout_merge_changes_only_owner_address() {
let mut doc = serde_json::json!({
"config_version": "v0.0.1",
"owned_identities": [{ "name": "merchant", "privatekey": "keep-secret" }],
"public_identities": [
{ "name": "owner", "pubkey": "not currently used", "lightning_address": "old@example.com" },
{ "name": "upstream", "lightning_address": "keep@example.com" }
]
});
let before_owned = doc["owned_identities"].clone();
let before_other = doc["public_identities"][1].clone();
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
assert_eq!(doc["owned_identities"], before_owned);
assert_eq!(doc["public_identities"][1], before_other);
assert_eq!(
doc["public_identities"][0]["lightning_address"],
"operator@example.com"
);
}
#[test]
fn payout_merge_can_create_missing_owner() {
let mut doc = serde_json::json!({ "public_identities": [] });
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
assert_eq!(doc["public_identities"][0]["name"], "owner");
assert_eq!(
doc["public_identities"][0]["lightning_address"],
"operator@example.com"
);
}
#[test]
fn payout_address_validation_rejects_typographical_failures() {
assert!(validate_payout_address("operator@example.com").is_ok());
for invalid in [
"",
"operator",
"@example.com",
"operator@",
"a@b@c",
"a b@example.com",
] {
assert!(
validate_payout_address(invalid).is_err(),
"accepted {invalid:?}"
);
}
}
#[test]
fn malformed_identity_data_is_never_replaced() {
let err = parse_identities("{ truncated").unwrap_err();
assert!(err
.to_string()
.contains("refusing to overwrite malformed identity data"));
}
}
+7
View File
@@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
config::apply(router, config)?;
wifi::provision_ssid(router, config)?;
// Must come after provision_ssid (creates the `tollgate` network this
// folds the upstream installer's own default AP onto) — see
// regate_upstream_default_aps for why this is needed at all.
wifi::regate_upstream_default_aps(router)
.context("re-gate upstream tollgate-module-basic-go default AP(s)")?;
// Must come after provision_ssid (which creates br-tollgate) and before
// the daemon restart below — config.json is only read at startup.
config::apply_daemon_config(router, config)
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
config::apply_payout_identity(router, config.payout_address.as_deref())
.context("write /etc/tollgate/identities.json owner payout address")?;
// Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config)
+71
View File
@@ -118,6 +118,61 @@ fn provision_firewall(router: &Router) -> Result<()> {
Ok(())
}
/// Fold the upstream `tollgate-module-basic-go` installer's own default
/// AP(s) onto the gated `tollgate` network.
///
/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot
/// scripts itself (no real package manager to trigger them on OpenWrt 25.x —
/// see its doc comment). Those upstream scripts rebrand OpenWrt's
/// factory-default wifi sections (`wireless.default_radioN`, present on
/// every fresh install) to a `TollGate-<serial>` SSID, but only ever touch
/// the SSID — they leave `network` at its original `lan` binding. Nothing
/// else in this project's own provisioning (`provision_ssid` above) ever
/// looks at those sections; it only manages the separate `wireless.tollgate`
/// SSID it creates itself. Left alone, the result is two open SSIDs
/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's
/// (wide open on `lan`, with a direct route to whatever's plugged into the
/// wired LAN port).
///
/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining
/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and
/// zero NoDogSplash involvement — free, unmetered internet, no captive
/// portal, on the router's own admin network.
///
/// Must run after `provision_network` (needs the `tollgate` network/bridge
/// to already exist) and before the network/wifi restart in
/// `restart_services` picks the new binding up.
pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
let sections = router.run_ok(
"uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u",
)?;
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
let network_key = format!("{}.network", section);
let current = router.uci_get(&network_key).unwrap_or_default();
let ssid = router
.uci_get(&format!("{}.ssid", section))
.unwrap_or_default();
// A failed/changed upstream first-boot script can leave a stock
// default_radioN section in place. Moving that interface merely
// because it is on LAN can seize the router's existing management AP.
// Only the public APs the TollGate installer demonstrably rebranded
// belong on the paid network.
if should_regate_upstream_ap(&current, &ssid) {
info!(
"[{}] Re-gating upstream default AP {} ({}) onto the tollgate network",
router.host, section, ssid
);
router.uci_set(&network_key, "tollgate")?;
}
}
router.uci_commit(Some("wireless"))?;
Ok(())
}
fn should_regate_upstream_ap(network: &str, ssid: &str) -> bool {
network.trim() == "lan" && ssid.trim().starts_with("TollGate-")
}
/// Return the first available wireless radio device name (e.g. "radio0").
fn detect_radio(router: &Router) -> Result<String> {
let out =
@@ -126,3 +181,19 @@ fn detect_radio(router: &Router) -> Result<String> {
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
Ok(radio)
}
#[cfg(test)]
mod tests {
use super::should_regate_upstream_ap;
#[test]
fn regates_only_confirmed_upstream_tollgate_aps() {
assert!(should_regate_upstream_ap("lan", "TollGate-3458"));
assert!(should_regate_upstream_ap(" lan\n", " TollGate-A1B2 "));
assert!(!should_regate_upstream_ap("lan", "OpenWrt"));
assert!(!should_regate_upstream_ap("lan", "Archipelago Admin"));
assert!(!should_regate_upstream_ap("tollgate", "TollGate-3458"));
assert!(!should_regate_upstream_ap("lan", "tollgate-3458"));
}
}
+40
View File
@@ -0,0 +1,40 @@
FROM docker.io/library/node:24-alpine AS build
# GitWorkshop has no release artifacts, so pin the audited source revision.
# The fetch verifies that the exact requested commit was checked out before any
# dependency or build command runs.
ARG GITWORKSHOP_COMMIT=dc36db64f6a2cca29d109829eabaf0a49d4bf4da
RUN apk add --no-cache git
WORKDIR /src
RUN git init \
&& git remote add origin https://github.com/DanConwayDev/gitworkshop.git \
&& git fetch --depth=1 origin "${GITWORKSHOP_COMMIT}" \
&& git checkout --detach FETCH_HEAD \
&& test "$(git rev-parse HEAD)" = "${GITWORKSHOP_COMMIT}"
COPY gitworkshop-archipelago.patch /tmp/gitworkshop-archipelago.patch
RUN git apply --check /tmp/gitworkshop-archipelago.patch \
&& git apply /tmp/gitworkshop-archipelago.patch
COPY gitworkshop-dependencies.patch /tmp/gitworkshop-dependencies.patch
RUN git apply --check /tmp/gitworkshop-dependencies.patch \
&& git apply /tmp/gitworkshop-dependencies.patch
RUN npm ci \
&& npm audit --audit-level=moderate
RUN APP_BASE_PATH=/app/archipelago-source/ \
APP_RELEASE_VERSION="archipelago-${GITWORKSHOP_COMMIT}" \
npm run build
FROM docker.io/library/nginx:1.27.4-alpine
COPY --from=build /src/dist/ /usr/share/nginx/html/
COPY nginx-main.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY UPSTREAM.md /usr/share/doc/archipelago-source/UPSTREAM.md
# Run both nginx master and workers as the packaged unprivileged user. Writable
# runtime paths live on the manifest's small mode-1777 `/tmp` tmpfs, so the
# container needs neither Linux capabilities nor a writable root filesystem.
EXPOSE 8337
ENTRYPOINT []
USER nginx
CMD ["nginx", "-g", "daemon off;"]
+31
View File
@@ -0,0 +1,31 @@
# GitWorkshop upstream
This image packages the GitWorkshop NIP-34 web client from:
- Source: https://github.com/DanConwayDev/gitworkshop
- Pinned commit: `dc36db64f6a2cca29d109829eabaf0a49d4bf4da`
- Upstream project: https://gitworkshop.dev/
- App icon: `public/icons/icon.svg` from the same pinned revision (the artwork
is only inset onto Archipelago's standard icon safe area).
The Archipelago integration patch only makes the upstream Vite/React
application work below Archipelago's `/app/archipelago-source/` mount, injects
the existing consent-gated Archipelago NIP-07 provider, disables the
development-only `localhost:4869` cache-relay probe, and removes two unreachable
lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces.
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
install reports zero npm advisories; its type-check, 152 unit tests, and
Archipelago subpath production build pass. Keeping this mechanical security
update separate makes both the upstream integration and future dependency
refreshes auditable.
The pinned revision and current upstream `main` do not contain a license file,
the package metadata declares no license, and GitHub reports no detected
license. Archipelago's owner explicitly accepted the resulting redistribution
risk on 2026-09-11. This is a project risk decision, not a claim that
GitWorkshop is licensed or that downstream recipients receive rights from its
copyright holders. An explicit upstream license remains the preferred,
auditable resolution.
@@ -0,0 +1,418 @@
diff --git a/index.html b/index.html
index 6894507..a917f5d 100644
--- a/index.html
+++ b/index.html
@@ -14,7 +14,7 @@
property="og:description"
content="Decentralized GitHub alternative over Nostr"
/>
- <meta property="og:image" content="/og-image.png" />
+ <meta property="og:image" content="%BASE_URL%og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
@@ -22,15 +22,19 @@
content="%APP_NAME% — git collaboration without the platform"
/>
<meta name="twitter:card" content="summary_large_image" />
- <meta name="twitter:image" content="/og-image.png" />
+ <meta name="twitter:image" content="%BASE_URL%og-image.png" />
<meta
http-equiv="content-security-policy"
- content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
+ content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' http: https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
/>
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
- <link rel="icon" type="image/png" href="/favicon.png" />
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
- <link rel="manifest" href="/manifest.webmanifest" />
+ <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
+ <link rel="icon" type="image/png" href="%BASE_URL%favicon.png" />
+ <link rel="apple-touch-icon" href="%BASE_URL%icons/apple-touch-icon.png" />
+ <link
+ rel="manifest"
+ href="/manifest.webmanifest"
+ crossorigin="use-credentials"
+ />
<style>
body {
margin: 0;
@@ -41,7 +45,9 @@
background: #16171e;
}
</style>
- <script src="/theme-init.js"></script>
+ <script src="%BASE_URL%theme-init.js"></script>
+ <script src="%BASE_URL%archipelago-nostrdb-config.js"></script>
+ <script data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script>
</head>
<body>
<div id="root">
@@ -119,7 +125,7 @@
<div id="splash-content">
<img
class="splash-logo"
- src="/icons/icon-192x192.png"
+ src="%BASE_URL%icons/icon-192x192.png"
width="64"
height="64"
alt=""
diff --git a/public/archipelago-nostrdb-config.js b/public/archipelago-nostrdb-config.js
new file mode 100644
index 0000000..8f598c4
--- /dev/null
+++ b/public/archipelago-nostrdb-config.js
@@ -0,0 +1,7 @@
+// window.nostrdb.js probes a developer-only relay at localhost:4869 unless
+// configured before the app module graph loads. On an installed node that
+// address means the user's own device, can never be the app's cache relay,
+// and is correctly blocked by GitWorkshop's production CSP.
+window.nostrdbConfig = Object.assign({}, window.nostrdbConfig || {}, {
+ localRelays: [],
+});
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 0f681e7..16dff02 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -20,6 +20,8 @@ import { MaintainerAcceptanceMonitor } from "./components/MaintainerAcceptanceMo
import { useRepoPath } from "./hooks/useRepoPath";
import { REPO_KIND } from "./lib/nip34";
import { getGitWorkshopPath } from "./lib/gitworkshopUrl";
+import { useLoginActions } from "./hooks/useLoginActions";
+import { accounts } from "./services/accounts";
/**
* Handles public GitWorkshop links in native builds. This stays inside the
@@ -365,9 +367,46 @@ function LegacyRedirect() {
return <RepoLayout />;
}
+/** Turn an eager node identity choice into GitWorkshop's extension login. */
+function ArchipelagoIdentityLogin() {
+ const login = useLoginActions();
+ const loginRef = useRef(login.extension);
+ const loginRunning = useRef(false);
+ loginRef.current = login.extension;
+
+ useEffect(() => {
+ const bridge = (
+ window as Window & {
+ archipelagoNostr?: {
+ onIdentitySelected?: (
+ callback: (identity: { nostr_pubkey: string }) => void,
+ ) => () => void;
+ };
+ }
+ ).archipelagoNostr;
+ if (!bridge?.onIdentitySelected) return;
+
+ return bridge.onIdentitySelected(() => {
+ if (accounts.getActive() || loginRunning.current) return;
+ loginRunning.current = true;
+ void loginRef
+ .current()
+ .catch((error) => {
+ console.error("Archipelago automatic login failed:", error);
+ })
+ .finally(() => {
+ loginRunning.current = false;
+ });
+ });
+ }, []);
+
+ return null;
+}
+
function AppRouter() {
return (
- <BrowserRouter>
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
+ <ArchipelagoIdentityLogin />
<NativeGitWorkshopLinks />
<NativeAndroidBackButton />
<ScrollToTop />
diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx
index 3eb76d2..22b079b 100644
--- a/src/components/AppFooter.tsx
+++ b/src/components/AppFooter.tsx
@@ -62,7 +62,7 @@ export function AppFooter() {
className="flex items-center gap-2 hover:opacity-80 transition-opacity w-fit"
>
<img
- src="/icons/icon.svg"
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
alt="GitWorkshop"
className="h-6 w-6"
/>
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index b31aa79..cec9d39 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -155,7 +155,11 @@ export function AppHeader() {
to="/"
className="group transition-opacity hover:opacity-80 shrink-0"
>
- <img src="/icons/icon.svg" alt="GitWorkshop" className="h-8 w-8" />
+ <img
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
+ alt="GitWorkshop"
+ className="h-8 w-8"
+ />
</Link>
<div className="flex items-center gap-2 ml-auto">
diff --git a/src/main.tsx b/src/main.tsx
index 1e4fead..ae3a045 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -10,9 +10,11 @@ import "@fontsource-variable/inter";
// itself, so subsequent loads are fully uncontrolled. Capacitor packages local
// assets and does not use this web-deployment cleanup worker.
if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
- navigator.serviceWorker.register("/sw.js").catch(() => {
- /* ignore — browser may block in certain envs */
- });
+ navigator.serviceWorker
+ .register(`${import.meta.env.BASE_URL}sw.js`)
+ .catch(() => {
+ /* ignore — browser may block in certain envs */
+ });
}
createRoot(document.getElementById("root")!).render(
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
index 18e3593..685c422 100644
--- a/src/pages/NotFound.tsx
+++ b/src/pages/NotFound.tsx
@@ -28,7 +28,7 @@ const NotFound = () => {
Oops! Page not found
</p>
<a
- href="/"
+ href={import.meta.env.BASE_URL}
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
Return to Home
diff --git a/src/services/settings.ts b/src/services/settings.ts
index 8f9a1a7..5438a72 100644
--- a/src/services/settings.ts
+++ b/src/services/settings.ts
@@ -124,8 +124,6 @@ export const fallbackRelaysCustomised$ = isCustomised$(
* These are used by the event loaders to find events more efficiently.
*/
export const DEFAULT_LOOKUP_RELAYS = normalizeRelayList([
- "wss://purplepag.es",
- "wss://index.hzrd149.com",
"wss://indexer.coracle.social",
]);
diff --git a/vite.config.ts b/vite.config.ts
index 0534fb9..d94fc70 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -39,37 +39,38 @@ function htmlAppNamePlugin(): Plugin {
*/
function manifestPlugin(): Plugin {
const virtualId = "/manifest.webmanifest";
+ const appBase = process.env.APP_BASE_PATH ?? "/";
const manifest = JSON.stringify(
{
name: "GitWorkshop.dev",
short_name: "GitWorkshop",
description: "Decentralized GitHub alternative over Nostr",
- start_url: "/",
+ start_url: appBase,
display: "standalone",
background_color: "#16171e",
theme_color: "#16171e",
categories: ["development", "productivity", "utilities"],
icons: [
{
- src: "/icons/icon-192x192.png",
+ src: `${appBase}icons/icon-192x192.png`,
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
- src: "/icons/icon-512x512.png",
+ src: `${appBase}icons/icon-512x512.png`,
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
- src: "/icons/pwa-maskable-192x192.png",
+ src: `${appBase}icons/pwa-maskable-192x192.png`,
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
- src: "/icons/pwa-maskable-512x512.png",
+ src: `${appBase}icons/pwa-maskable-512x512.png`,
sizes: "512x512",
type: "image/png",
purpose: "maskable",
@@ -82,6 +83,12 @@ function manifestPlugin(): Plugin {
return {
name: "manifest",
+ transformIndexHtml(html) {
+ return html.replace(
+ 'href="/manifest.webmanifest"',
+ `href="${appBase}manifest.webmanifest"`,
+ );
+ },
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === virtualId) {
@@ -104,6 +111,10 @@ function manifestPlugin(): Plugin {
// https://vitejs.dev/config/
export default defineConfig(() => ({
+ // Archipelago serves GitWorkshop behind the dashboard origin. Vite's base
+ // controls emitted asset URLs while BrowserRouter consumes the same value
+ // below, so repository routes remain valid below that mount point.
+ base: process.env.APP_BASE_PATH ?? "/",
define: {
__APP_NAME__: JSON.stringify(name),
__APP_RELEASE_VERSION__: JSON.stringify(
diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx
index f59a7d2..3168910 100644
--- a/src/components/auth/AccountSwitcher.tsx
+++ b/src/components/auth/AccountSwitcher.tsx
@@ -46,7 +46,7 @@ function SignerTypeBadge({ account }: { account: IAccount }) {
return (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Puzzle className="w-3 h-3" />
- Extension
+ Extension / Archipelago
</span>
);
if (account instanceof NostrConnectAccount)
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx
index 11f6716..0bba6a2 100644
--- a/src/components/auth/LoginDialog.tsx
+++ b/src/components/auth/LoginDialog.tsx
@@ -239,9 +239,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
try {
if (!("nostr" in window)) {
throw new Error(
- "Nostr extension not found. Please install a NIP-07 extension.",
+ "No NIP-07 signer found. Open GitWorkshop through Archipelago or install a browser extension.",
);
}
+ const archipelago = (
+ window as Window & {
+ archipelagoNostr?: { selectIdentity?: () => Promise<unknown> };
+ }
+ ).archipelagoNostr;
+ if (archipelago?.selectIdentity) await archipelago.selectIdentity();
await login.extension();
onLogin();
onClose();
@@ -437,7 +443,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
disabled={isLoading}
>
<Puzzle className="w-4 h-4" />
- {isLoading ? "Logging in..." : "Log in with Extension"}
+ {isLoading
+ ? "Logging in..."
+ : "Log in with Extension / Archipelago"}
</Button>
)}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index b8de377..7f72f31 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -23,6 +23,7 @@ import {
ChevronUp,
Pin,
Search,
+ Globe2,
} from "lucide-react";
import { CreateRepoDialog } from "@/components/CreateRepoDialog";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ import { useUserActivity } from "@/hooks/useUserActivity";
import { useUserRepositories } from "@/hooks/useUserRepositories";
import { useUserFollowedRepos } from "@/hooks/useUserFollowedRepos";
import { useUserPinnedCoords } from "@/hooks/useUserPinnedRepos";
+import { useRepositorySearch } from "@/hooks/useRepositorySearch";
import { useNotifications } from "@/hooks/useNotifications";
import { useUserProfileSubscription } from "@/hooks/useUserProfileSubscription";
import { useUserPath } from "@/hooks/useUserPath";
@@ -409,6 +411,63 @@ function FollowedReposPanel({ pubkey }: { pubkey: string }) {
);
}
+// ---------------------------------------------------------------------------
+// Recent repositories from the wider Nostr network
+// ---------------------------------------------------------------------------
+
+function NetworkRepositoriesPanel() {
+ const { repos, isLoading } = useRepositorySearch("");
+ const recent = repos?.slice(0, 6);
+
+ return (
+ <div className="h-fit">
+ <div className="pb-3 flex items-center justify-between gap-3">
+ <h3 className="text-base font-semibold flex items-center gap-2">
+ <Globe2 className="h-4 w-4 text-muted-foreground" />
+ Nostr network
+ </h3>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
+ asChild
+ >
+ <Link to="/search">
+ Browse all
+ <ArrowRight className="h-3 w-3 ml-1" />
+ </Link>
+ </Button>
+ </div>
+
+ {recent === undefined || (isLoading && recent.length === 0) ? (
+ <div className="space-y-1">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <RepoRowSkeleton key={i} />
+ ))}
+ </div>
+ ) : recent.length > 0 ? (
+ <div className="space-y-0.5">
+ {recent.map((repo) => (
+ <RepoListItem
+ key={`${repo.selectedMaintainer}:${repo.dTag}`}
+ repo={repo}
+ />
+ ))}
+ </div>
+ ) : (
+ <div className="py-6 text-center">
+ <p className="text-sm text-muted-foreground">
+ No network repositories available
+ </p>
+ <p className="text-xs text-muted-foreground/60 mt-1">
+ Check the git index relay in Settings
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
+
// ---------------------------------------------------------------------------
// Embedded notifications panel (compact, inbox only, max 5)
// ---------------------------------------------------------------------------
@@ -591,6 +650,8 @@ export function Dashboard() {
<MyRepositoriesPanel pubkey={pubkey} />
<Separator className="opacity-40" />
<FollowedReposPanel pubkey={pubkey} />
+ <Separator className="opacity-40" />
+ <NetworkRepositoriesPanel />
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr notice;
events {
worker_connections 256;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
keepalive_timeout 65;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/conf.d/*.conf;
}
+42
View File
@@ -0,0 +1,42 @@
server {
# Host networking is required for the loopback-only Archipelago RPC.
# Keep nginx itself on loopback so the authenticated app gate owns every
# externally reachable listener.
listen 127.0.0.1:8337;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = /manifest.webmanifest {
default_type application/manifest+json;
try_files $uri =404;
}
location = /app/archipelago-source/manifest.webmanifest {
default_type application/manifest+json;
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
try_files $uri =404;
}
# The normal dashboard proxy strips this prefix before forwarding, while
# direct app-gate access preserves it. Supporting both keeps health/debug
# access useful without making launch depend on any particular interface.
location ^~ /app/archipelago-source/ {
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
location / {
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}
+243
View File
@@ -0,0 +1,243 @@
# Archipelago 1.8.12-alpha Release Plan
**Target date:** 2026-09-11
**Scope:** OTA, signed app registry, canonical Nostr source publication,
Companion 0.5.32 alpha, and an x86_64 installer ISO
**Candidate base:** `f5c0ba85` plus the release-preparation changes documented
here
## Release Outcome
This release is complete only when all of the following are true:
1. Existing nodes can install the signed OTA and retain AIUI, app data, app
manifests, Nostr identities, Lightning state, and network configuration.
2. A fresh ISO installation boots, reports `1.8.12-alpha`, and serves the same
dashboard/runtime payload as the OTA.
3. The signed app registry contains GitWorkshop, its embedded manifest, and the
registry-owned Source promotion.
4. Archipelago's canonical repository is announced by
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg` as the
stable identifier `archy`, is cloneable through `nostr://`, and opens in
GitWorkshop from the Source promotion.
5. Contributors can create an issue or proposal without gaining canonical
merge, release-tag, catalog-signing, or OTA-signing authority.
Shipping GitWorkshop alone does not satisfy item 4. The client is the interface;
the signed NIP-34 announcement and reachable Git object servers are the source
publication.
## Audited Candidate State
### Ready
- The accumulated candidate is committed and the development node is healthy.
- GitWorkshop is healthy behind the authenticated same-origin app gate.
- The app works as a top-level Companion WebView and as a browser tab; the node
signer remains consent-gated and identity-switchable.
- The Source icon, description, banner, Popular/All Apps placement, and
maintainer npub are present in the development catalog.
- Payment receipts, Minibits claim persistence, update cancellation, app
credential interstitials, Gitea limits, certificate guidance, and the pasta
listener repair are included in the candidate.
- `ngit 2.6.3` and `git-remote-nostr 2.6.3` are installed on the node.
- The existing public Gitea source and Smart HTTP clone endpoints respond with
HTTP 200.
- The release-root signer exists locally as a binary, while its mnemonic
remains correctly offline.
- ISO dependencies (`xorriso`, `mksquashfs`, and QEMU) are installed.
- The corrected release gate passed on 2026-09-11: 1,093 frontend tests,
production UI build, registry candidate/trust checks, Rust check, 114 focused
backend tests plus orchestration, and live dashboard/AIUI/RPC probes. Android
lint and the mobile Chromium signer-handoff regression also pass.
- GitWorkshop's refreshed dependency lock reports zero production or
development npm advisories. Its type-check, all 152 unit tests, and exact
Archipelago subpath production build pass with `fflate` 0.8.3, React Router
7.18.3, and Vitest 5.0.0.
- The locally served Companion candidate is 28,157,847 bytes with SHA-256
`30e0b6257f17bb5e072941ccce6fde04b6c8ae59a49b45169e1920aec1b108cf`.
- The Discover banner and automatic Companion prompt read that artifact's
no-cache metadata, display 0.5.32 build 52, and remember dismissal per build;
the ISO gate derives the same expected version from the Android project.
- The owner accepted the risk of redistributing the pinned, unlicensed
GitWorkshop revision on 2026-09-11. `NOTICE` and `UPSTREAM.md` disclose that
decision without claiming an upstream license.
### Not Yet Complete
- This checkout is not logged into `ngit` and has no Nostr repository remote or
cached kind `30617` announcement.
- The production signed catalog still contains 61 apps but not
`archipelago-source` or `storefront`; a freshly generated candidate contains
62 apps, 61 embedded manifests, GitWorkshop 0.4.0, and the Source promotion.
- The previous public Companion download was 0.5.28-debug. A 0.5.32-debug
candidate has now been built with the same certificate and can update that
alpha channel; the accepted 0.5.32-uat build remains a separate package.
- The shared debug certificate is intentionally public and its private key is
reachable in an existing public Git branch. It provides upgrade continuity,
not production authenticity. A future production Companion must use the
`com.archipelago.app` package with a privately held release key.
- The repository is large: this checkout has about 5.4 GiB of packed objects,
and the Gitea repository is about 5 GiB. Public GRASP replication must be
allowed enough time and verified rather than assumed complete.
- The provided secrets audit reports known false positives in documentation,
path-variable names, and deliberate test keys. Before Nostr publication,
record that review and ensure local Git remote credentials are rotated; local
`.git/config` credentials are not tracked and must never be copied into an
announcement or release log.
## Required Owner Inputs
These secrets must be entered interactively and must not be sent in chat,
committed, placed in shell history, or stored on the node:
1. Access to the Nostr maintainer identity matching the npub above. Prefer an
interactive NIP-46 connection through `ngit account connect`; do not pass an
nsec on a command line.
2. The 24-word Archipelago release-master mnemonic for three signing actions:
the OTA manifest, the app catalog, and the ISO checksum document.
3. A decision to publish 0.5.32 on the existing explicitly non-production
`.debug` Companion channel, omit it, or pause for a secure production-app
signing ceremony.
The shared Companion update certificate is not a production secret. It was
temporarily recovered from the public branch, verified against the distributed
0.5.28 APK, used to build the 0.5.32 alpha upgrade candidate, and removed from
the working tree. Neither it nor the UAT keystore belongs in the release commit.
## Execution Order
### Gate A — Freeze And Production Companion
1. Verify the shared Companion certificate against the existing public APK
before building anything. This is complete for the alpha candidate.
2. Build the 0.5.32 shared-key alpha APK with v1, v2, and v3 signature schemes.
3. Verify package ID, version code/name, signing digest, and install-as-upgrade
behavior on the owner's phone.
4. Stage the APK and its JSON version metadata in the dashboard payload.
5. Run formatting, frontend type checking/unit tests/build, Android lint/build,
focused backend tests, manifest validation, registry-candidate validation,
secret review, and live node smoke probes.
6. Deploy that exact build on this node and perform the short final UAT:
GitWorkshop and IndeedHub first login, identity switching, Companion WebView,
external tab, certificate install, payment receipt, update cancellation, and
credential interstitial.
Stop on any failure. Do not sign or publish a workaround build.
### Gate B — Publish Archipelago Source Through Nostr
Run Nostr publication from a disposable clean clone so `ngit init` cannot
repoint or rewrite the release checkout's remotes.
1. Confirm the clean clone matches the final candidate and contains `LICENSE`,
`CONTRIBUTING.md`, `SECURITY.md`, and `NOTICE`.
2. Connect `ngit` interactively to the canonical maintainer identity and verify
`ngit account whoami` reports the expected npub.
3. Publish identifier `archy`, title `Archipelago`, the public project
description/homepage, the existing Gitea HTTPS clone URL, and multiple
current public GRASP services. Use at least `relay.ngit.dev` and
`gitnostr.com`; add an Archipelago-operated GRASP service later without
changing the repository coordinate.
4. Allow the complete repository push to finish. Do not treat a published
announcement as proof that all Git objects arrived.
5. Verify the announcement author, identifier, relays, clone URLs, default
branch, and release tags with `ngit repo`.
6. In a second empty directory, clone the printed `nostr://` URL and compare its
`HEAD` tree to the candidate.
7. Open
`/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy`
in the packaged GitWorkshop and confirm README/code browsing works.
8. Publish a test issue/proposal from a non-maintainer identity, review it from
the maintainer identity, and confirm it cannot change canonical refs without
maintainer authorization.
9. Confirm clone/browse still work with one GRASP server unavailable. A later
infrastructure release must add an Archipelago-operated GRASP endpoint; the
signed coordinate remains stable when servers change.
The Source promotion carries the deterministic npub/identifier deep-link. It
must not be published to the registry until steps 5–8 pass.
### Gate C — Prepare And Publish OTA 1.8.12-alpha
1. Convert the top `Unreleased` changelog section into
`v1.8.12-alpha (2026-09-11)` and synchronize What's New.
2. Run `bash scripts/create-release.sh 1.8.12-alpha`. Enter the release mnemonic
once when prompted and terminate input with Ctrl-D.
3. Verify the new release commit, annotated tag, staged backend/frontend
artifacts, signed pending manifest, embedded AIUI, APK version, and clean
tree.
4. Publish with
`SKIP_ISO=1 scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
The script uploads and hashes artifacts before pushing the fleet-visible
manifest. Never push `main` manually around this ordering.
5. Verify both local and public manifests, both asset hashes, public HTTPS
download links, and update availability from this node.
6. Apply the OTA to this node first, reboot if requested, and rerun live smoke
probes before allowing the catalog publication.
### Gate D — Publish The Signed App Registry
This follows the OTA canary because GitWorkshop's manifest builds from
`/opt/archipelago/docker/archipelago-source`, which older nodes do not have.
1. Generate `releases/app-catalog.json` from the final manifests.
2. Require 62 entries, 61 embedded manifests, `archipelago-source` 0.4.0,
registry-owned storefront metadata, and the canonical Source deep-link.
3. Run strict metadata, manifest, and deployed-registry trust-floor checks.
4. Sign the exact catalog bytes using `bash scripts/sign-catalog.sh` and verify
the pinned release-root DID.
5. Commit and push only the signed catalog after the OTA canary succeeds.
6. Confirm `/api/app-catalog` verifies the signature and a fresh node can see,
install, launch, stop, start, and uninstall GitWorkshop.
### Gate E — Build, Test, Sign, And Attach The ISO
1. From clean `main` with the live signed 1.8.12 manifest and tag, run
`bash scripts/build-iso-release.sh`. Do not use `--skip-gates` or `--no-qemu`
for the publishable candidate.
2. Require the release harness, strict catalog check, full Rust test suite,
version/artifact checks, ISO mount smoke test, and QEMU boot attempt.
3. Record the ISO filename, byte size, SHA-256, build commit, and whether QEMU
was conclusive. If QEMU is inconclusive, boot the ISO on the target test
machine before publication.
4. Sign the checksum document with
`bash scripts/sign-iso-checksums.sh <iso>` and cryptographically verify it.
5. Attach the ISO, plain checksum, and signed checksum JSON by rerunning
`scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
6. Verify Gitea's stored sizes and public HTTPS links, then download/hash the
public checksum documents. Flash/boot/install on real hardware before
calling the ISO generally available.
## Rollback Rules
- OTA: the live manifest remains 1.8.11-alpha until all 1.8.12 artifacts have
uploaded and verified. If canary application fails, do not publish the new
catalog; restore the previous manifest/artifacts through the established
rollback path.
- Registry: retain the previously signed catalog. Roll back by publishing a
newly signed catalog that removes GitWorkshop or restores its prior entry;
never edit signed JSON in place.
- Nostr source: repository state is authoritative only when signed by the
configured maintainer. Add replacement servers before removing failed ones,
then republish and run `ngit sync`.
- ISO: an ISO is not referenced by the OTA manifest. A failed ISO build/upload
cannot block or corrupt an already verified OTA; do not advertise a partial
attachment.
- Companion: retain 0.5.28 and the verified alpha signer until 0.5.32 installs
as an upgrade. Never replace the public APK with the differently signed UAT
build. Do not describe the public debug-key channel as production-secure.
## Final Release Record
Before announcing completion, record in the release notes:
- release/tag/commit and signed manifest DID;
- backend and frontend SHA-256 values;
- signed catalog commit, app count, and verification result;
- canonical `nostr://` URL, NIP-34 coordinate, GRASP/clone endpoints, and clone
verification commit;
- Companion package/version/signing digest and APK SHA-256;
- ISO filename/size/SHA-256/signature and smoke/QEMU/real-hardware results;
- owner UAT acceptance and the explicit GitWorkshop no-license/dependency-risk
decisions.
+1 -1
View File
@@ -132,7 +132,7 @@ curl -s http://<node>/rpc/v1 -b jar.txt -H 'Content-Type: application/json' \
Login returns a `session` cookie. State-changing calls also need the `X-CSRF-Token` header. Exactly twelve read-only methods are CSRF-exempt, so for those the cookie alone is enough:
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-version` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-hostname` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
Anything not on that list — including `bitcoin.getinfo` and `monitoring.current` — needs the CSRF header. If TOTP is enabled, follow the login with `auth.login.totp`.
+1 -1
View File
@@ -129,7 +129,7 @@ The ISO redistributes a full Debian (trixie) system plus ~29 container image tar
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
+211 -29
View File
@@ -31,9 +31,6 @@ app:
entrypoint: ["sh", "-lc"]
custom_args:
- /app/start.sh
derived_env:
- key: PUBLIC_URL
template: https://{{HOST_MDNS}}:8180
secret_env:
- key: APP_PASSWORD
secret_file: my-app-password
@@ -55,6 +52,8 @@ app:
- host: 8180
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
@@ -125,13 +124,59 @@ app:
| `app.environment` | Static `KEY=value` environment entries |
| `app.health_check` | HTTP or TCP health check settings |
| `app.devices` | Explicit device paths |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
### Iframe embedding — the rules
#### What Archipelago decides, and what the app must declare
Archipelago works out the reachable hostname and browser scheme at launch
time. An app must not bake a LAN IP, Tailscale IP, FIPS address, `.local`
name, or the dashboard's current `http`/`https` scheme into its UI URL.
Declare the UI once in `interfaces.main`, put the matching port behind the
app gate, and use relative URLs for the app's own assets and links.
| Concern | App author | Archipelago |
|---|---|---|
| UI location | Declare `interfaces.main.port`, `protocol`, and `path` | Uses the address through which this browser reached the node |
| Exposure | Bind a `gated`/`open` port to `127.0.0.1` | Publishes it on supported LAN, Tailscale, FIPS, and Tor ingress |
| HTTP/HTTPS | Serve the declared upstream protocol locally | Keeps HTTP pages on HTTP; on an HTTPS dashboard, gate-fronted app ports use HTTPS on the same port |
| Embedded or top-level | Default to iframe; declare an exception when required | Chooses iframe, browser tab, or companion-native view from generated launch metadata |
| Navigation | Use relative same-app URLs and normal absolute external URLs | Preserves the selected node address and routes external links out of the companion app view |
`interfaces.main.protocol` describes the service behind the gate. It does
not tell application code to hard-code that scheme into browser links: the
gate can terminate TLS in front of a locally plain-HTTP container.
There are two important limits:
- `auth: none` bypasses the gate, so Archipelago cannot add TLS or make that
port safe to embed from an HTTPS dashboard. Use it for protocols, not
ordinary web UIs.
- Same-origin mounts such as `/app/archipelago-source/` are platform-owned
integrations. A normal app cannot request an arbitrary dashboard path in
its manifest; use `interfaces.main` and a gated port.
When the platform does provide one of those same-origin mounts, the nginx
location must pass its exact mount as `X-Forwarded-Prefix` to the app gate:
```nginx
location /app/example/ {
proxy_pass http://127.0.0.2:8123/;
proxy_set_header X-Forwarded-Prefix /app/example;
}
```
The trailing slash on `proxy_pass` strips the mount from the upstream request;
the header lets the gate put it back into its login form, login-page assets,
and successful redirect. Omitting it makes a fresh mobile-browser session post
to the dashboard's root `/__archipelago-gate/login`, which is not an app-gate
endpoint and will normally return 405. This header is host integration config,
not app-controlled manifest metadata, and must be a fixed literal path.
The dashboard opens apps in an **embedded frame** (My Apps → app session) by
default. Whether that works is decided by HTTP headers, not by wishes, so
know the mechanics:
@@ -145,7 +190,7 @@ know the mechanics:
behind Archipelago's app gate the clickjacking threat those headers address
is already handled — every proxied request is authenticated by the gate
first.
- Therefore **the gate neutralizes frame blocking on proxied responses**: it
- Therefore **the gate neutralizes frame blocking on gate-fronted responses**: it
removes `X-Frame-Options` and strips only the `frame-ancestors` directive
from the app's CSP. The rest of the app's CSP (script-src, connect-src, …)
passes through untouched — the gate never weakens the app's own content
@@ -214,6 +259,37 @@ underscores. Supported interface types are `ui`, `api`, and `metrics`; only
`type: ui` is treated as a launchable app surface. Supported protocols are
`http` and `https`, and `path` must start with `/`.
### Browser, iframe, and companion launch modes
Launch behavior is generated from the manifest. Application code should not
sniff for a particular node IP or companion user-agent.
```yaml
metadata:
launch:
# Use only for OAuth/WebAuthn, JS frame-busting, or another top-level
# browser requirement that the gate cannot repair.
open_in_new_tab: false
# Keep a different, app-specific parent-frame integration alive in the
# Android companion. Standard Archipelago NIP-07 no longer needs this.
requires_host_frame: false
```
- Desktop/PWA: iframeable apps stay in the dashboard.
`open_in_new_tab: true` apps open in a browser tab.
- Android companion: ordinary apps open in the native in-app browser with its
own navigation controls. `requires_host_frame: true` apps stay in the
dashboard iframe so `window.parent.postMessage` integrations remain alive.
- Never set both flags. A top-level page cannot simultaneously require its
parent frame.
- Relative app paths are resolved against the active dashboard origin before
a native launch, so the same package works through LAN, Tailscale, and FIPS.
Test all four relevant paths before submission: HTTP dashboard iframe, HTTPS
dashboard iframe with the node CA installed, companion launch, and every
external link or login redirect that leaves the app.
### Nostr Signer Bridge (NIP-07)
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
@@ -221,43 +297,149 @@ events without managing their own keys. Archipelago injects a **NIP-07 provider*
(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges
to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.
**How injection works.** After install, the host copies `nostr-provider.js` into the
app container and patches the app's web server so every page loads it and the app is
iframe-embeddable. This is **best-effort** and depends on your server config exposing
the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g.
IndeeHub) your `nginx.conf` must satisfy this contract:
**How injection works.** The dashboard owns the consent UI and postMessage
host, and ships the canonical `nostr-provider.js`, but generic containers are
not silently rewritten. Package the provider explicitly with a manifest
`copy_from_host` hook (or bake the same provider into the image) and inject it
into every HTML document your app serves. IndeeHub's manifest is the
hook-based reference; Archipelago Source's outer same-origin nginx mount is a
platform-owned reference.
1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host
strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive
CSP `frame-ancestors` will still block embedding.
2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache
`location = /nostr-provider.js` block is inserted immediately before it.
3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A
`sub_filter` that injects `<script src="/nostr-provider.js"></script>` before
`</head>` is inserted right after it. (nginx must have `ngx_http_sub_module` —
stock `nginx:alpine` does.)
For an **nginx-served SPA**, use this contract:
1. **Be iframe-embeddable.** The app gate removes `X-Frame-Options` and only
the CSP `frame-ancestors` directive from responses, but your own config
should still express the intended embedded deployment rather than relying
on repair.
2. Serve `/nostr-provider.js` with `Cache-Control: no-cache, no-store`. Never
precache the provider or the dashboard `/nostr-signer` navigation in an app
service worker; signing protocol updates must reach existing installations.
3. Inject a versioned provider URL such as
`<script src="/nostr-provider.js?v=tab-signer-v4"></script>` before
`</head>` in every SPA document. The token prevents an older iframe-only
provider from surviving a dashboard update in the browser's asset cache.
`sub_filter` is suitable when nginx has
`ngx_http_sub_module` (stock `nginx:alpine` does).
4. **If you proxy an API that does NIP-98 URL verification**, expose
`proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the
outer reverse proxy's prefix.
The patch is **idempotent** (it checks for an existing `nostr-provider` reference
before editing) and re-runs on reinstall. If you rename or remove any of the anchor
strings above, injection silently no-ops and `window.nostr` will be undefined in your
app — so guard those lines in your config (see the contract comment block at the top of
IndeeHub's `nginx.conf` for a template).
Make the hook **idempotent** and fail its verification step if the provider is
not present after install. A silent no-op leaves `window.nostr` undefined and
is not release-ready.
> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either
> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML;
> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node.
> Non-nginx servers (Next.js `node server.js`, etc.) should ship the provider
> themselves and reference it in their HTML; the canonical host copy is
> `/opt/archipelago/web-ui/nostr-provider.js`.
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
Choose the launch mode for the app itself; the signer works in either shape:
```yaml
metadata:
launch:
open_in_new_tab: false # default; set true only if the app cannot be iframed
open_in_new_tab: false
requires_host_frame: false
```
The provider supports both launch shapes. In a dashboard iframe it talks to
the dashboard parent directly. In a browser tab or the companion's standalone
WebView it creates a dashboard-origin signer frame, which renders the same
identity chooser and consent card over the app and relays NIP-07 requests to
the authenticated node session. It deliberately does not depend on
`window.opener`, so `noopener` tab launches remain safe and functional.
The app gate's successful login supplies the host-wide session and CSRF cookie
pair in a fresh external browser; the signer broker validates that session
directly and does not require the browser to have visited or logged into the
dashboard first. Existing session-only browser tabs are repaired on their next
gate-fronted app response. Do not add a second dashboard-login prerequisite in
application code.
For that reason a NIP-07 app does **not** need `requires_host_frame: true`.
Use the flag only if the app has some other parent-frame protocol. If a
top-level app sends its own `Content-Security-Policy`, its `frame-src` must
permit the dashboard origin; apps intended to work over every node address can
allow `http:` and `https:` while relying on the provider's strict same-host
parent validation. A policy limited to `frame-src 'self'` will block the
broker when the app is running on a different port.
**Consent UI belongs to the platform.** Do not build a second signer modal,
request a top-level window, or overlay the entire dashboard. A standard NIP-07
call pauses while Archipelago shows its contained consent card inside the
active app surface. After approval, the shared Nostr identity ring provides a
short signing loader and completion state. The same host-owned flow renders in
desktop browsers, installed PWAs, and the Android companion WebView.
Silent background requests and remembered approvals deliberately keep the
broker frame hidden; only an identity choice or an actual consent prompt may
reveal it. If an app performs NIP-98 bootstrap and then navigates, it must wait
for the provider Promise to finish rather than independently reloading while
the consent result is still visible. The canonical provider coordinates its
automatic IndeeHub-style session reload with the broker's hide notification.
For top-level apps, that broker document must remain transparent. When hidden,
its iframe must stay loaded but be reduced to a non-interactive 1px surface and
parked physically off-screen. Removing/display-hiding the full-viewport iframe,
or leaving it full-size with only `visibility:hidden`, can make Android WebView
and mobile Chromium retain its last black/grey compositor surface above a
healthy app until refresh. Keeping one parked broker also prevents a visible
hide/recreate flash between `getPublicKey` and `signEvent`. The canonical
provider owns this lifecycle; apps must not copy or manipulate its iframe.
Apps should treat the NIP-07 Promise as an ordinary asynchronous operation:
disable only the initiating control, preserve the user's draft, handle a user
denial as a normal rejected request, and render the returned result when it
resolves. Never infer approval from elapsed time and never ask the user for an
`nsec` as a fallback.
Archipelago recognizes a synchronous, user-triggered `getPublicKey()` as an
account-selection action. An Archipelago-packaged app should still ask the host
to show the identity chooser explicitly before login, especially when other
asynchronous work happens between the click and the NIP-07 call. This prevents
a returning user from being silently locked to the identity chosen on first use:
```js
await window.archipelagoNostr?.selectIdentity?.()
const pubkey = await window.nostr.getPublicKey()
```
`archipelagoNostr.selectIdentity()` is an optional host enhancement, not part of
NIP-07. Apps must continue to work when it is absent (for example with a normal
browser extension). Invoke it only from a deliberate login/account-switch
action; routine signing calls should continue using the remembered identity.
If a first-launch choice should create the app account automatically, use the
provider's sticky identity subscription and call the ordinary extension-login
action from it:
```js
const unsubscribe = window.archipelagoNostr?.onIdentitySelected?.(() => {
if (!alreadyLoggedIn()) loginWithNip07()
})
```
The callback runs immediately when an identity was selected just before the
React/Vue component mounted, closing the load-event race seen in browser tabs
and Companion WebViews. Call `unsubscribe()` when the component unmounts. The
selected public key remains available to the immediately following
`getPublicKey()` call; do not add a timeout, reload, or second lookup between
those operations. A plain `archipelago:identity` message remains available for
backward compatibility, but it is not a reliable framework lifecycle API.
Submission testing for a Nostr-signed app must include:
1. `getPublicKey` allow, deny, and remembered consent;
2. `signEvent` with a readable event-kind/content preview;
3. the contained review → identity-ring loader → completion sequence;
4. changing the selected identity and confirming remembered consent does not
cross identity boundaries;
5. HTTP and HTTPS dashboard frames, a `noopener` browser-tab launch, and the
Android companion's standalone WebView;
6. choosing an identity immediately when the first-launch picker appears, to
prove the app's account store is ready before the result arrives; and
7. Companion → **Open in browser** in a browser with no prior dashboard
localStorage: complete the app gate, then prove the contained signer can
choose an identity and sign without asking for a second node login; and
8. after the first identity choice and after NIP-98 authentication, confirm the
underlying app paints immediately—no black frame and no manual reload.
## Security Requirements
Two different things enforce these, and it's worth knowing which is which:
+31
View File
@@ -173,6 +173,37 @@ override wins over the manifest in both directions and applies on the next
request — your app cannot assume the gate is or isn't in front of it, so it
must always enforce its own authorization for sensitive operations.
## Launch metadata
`metadata.launch` is consumed by catalog generation and the dashboard
launcher. It is currently an extension rather than a Rust-validated field:
```yaml
metadata:
launch:
open_in_new_tab: false
requires_host_frame: false
```
| Field | Default | Meaning |
|---|---|---|
| `open_in_new_tab` | `false` | The app must be top-level because header repair cannot solve its OAuth/WebAuthn flow, JavaScript frame-busting, or strict cookies. Desktop opens a browser tab; Android uses its native in-app browser. |
| `requires_host_frame` | `false` | Keep the app in the dashboard iframe even in the Android companion because it consumes an app-specific parent-frame integration. Standard Archipelago NIP-07 works in iframes, tabs, and the companion WebView without this flag; the platform renders consent inside the active app surface. |
Do not set both fields to `true`. The generated TypeScript launch tables are
the runtime source used by the dashboard, so run
`python3 scripts/generate-app-catalog.py` after changing either value. See
[`app-developer-guide.md`](app-developer-guide.md#browser-iframe-and-companion-launch-modes)
for the HTTP/HTTPS and test matrix.
Platform-owned same-origin mounts are not manifest features. If Archipelago
adds one, its nginx location must send a fixed
`X-Forwarded-Prefix: /app/<id>` header to the app gate whenever `proxy_pass`
strips that prefix. The gate uses it for challenge form/assets and the
post-login redirect; without it, a fresh external browser posts to the
dashboard root and receives 405. Ordinary registry apps should declare a
gated `interfaces.main` port instead of requesting such a mount.
## Volumes
```yaml
+230 -198
View File
@@ -1,252 +1,284 @@
# Nostr Git Source Hosting Plan
This plan describes how Archipelago can publish and accept contributions to its
source code through `ngit`, NIP-34, and GRASP while keeping the developer
experience inside Archipelago.
**Reviewed:** 2026-09-11
## Goals
**Status:** GitWorkshop integration is deployed and engineering-tested on the
development node. The owner reported the corrected flows working and requested
a production release on 2026-09-11. Canonical repository publication remains
incomplete: the checkout has no Nostr repository configuration and `ngit` is
not logged into the canonical maintainer identity. The signed production app
catalog also does not yet carry GitWorkshop or its storefront promotion.
- Publish Archipelago source from a sanitized, fresh-history repository.
- Make the in-app registry the primary onboarding path for contributors.
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
with a Nostr identity from their Archipelago node.
- Follow the Bitcoin Core development model: broad public review and easy forks,
with canonical merge authority held by a small maintainer set.
- Give contributors full read, fork, and proposal rights, but no direct merge
rights on the canonical repository.
- Keep the official maintainer identity and merge authority separate from user
node identities.
The Android companion opens Source as a top-level page in its native in-app
WebView. GitWorkshop's injected NIP-07 provider creates a small authenticated
dashboard-origin signer broker within that page, so the app itself is never
kept in a dashboard iframe. The App Store carries the upstream GitWorkshop
icon, source-focused copy, and a dedicated contribution banner. Popular ordering and promotional
placement are registry-owned `storefront` metadata rather than node-OS UI
policy; these are also part of owner UAT.
## Current Building Blocks
## Goal
Archipelago already has most of the primitives needed for this:
Archipelago users can install a Source app from the app registry, obtain the
Archipelago source, browse it, and contribute through the established Nostr Git
ecosystem. Git remains the version-control engine, Nostr NIP-34 carries
repository identity and collaboration events, and GRASP transports Git objects.
- App manifests and the app registry already install developer tooling as
rootless Podman apps.
- The `gitea` app provides a conventional fallback Git UI and package registry.
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
- The backend exposes node and identity Nostr signing RPC methods.
- FIPS gives nodes a stable mesh identity and private transport path, but repo
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
- DWN protocol registration exists and can be used later for local contribution
metadata/cache, but should not be required for the first public workflow.
The app must make public contribution easy without giving contributors direct
merge or release authority. Canonical refs, merge status, release tags, and
catalog signatures remain controlled by explicitly configured Archipelago
maintainers.
## Protocol Basis
## Product Decision
Use existing Nostr Git conventions rather than inventing an Archipelago-only
protocol:
Archipelago will package the upstream GitWorkshop web client instead of
building another NIP-34 repository interface.
- NIP-34 repository announcement events identify repositories with kind `30617`.
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
branches.
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
authority for repository identity, refs, PRs, issues, and maintainer state.
GitWorkshop already provides repository discovery, a sparse Git explorer,
issues, pull requests, and review workflows. Archipelago owns only the node
integration around it:
- installable app metadata and a pinned upstream build;
- a same-origin `/app/archipelago-source/` launch path that works through the
dashboard address the user already opened, whether that is LAN, Tailscale,
FIPS, DNS, IPv4, or IPv6;
- authenticated routing through the existing app gate;
- an injected, consent-gated NIP-07 provider so GitWorkshop can use a selected
node identity without receiving its private key;
- source provenance, security validation, upgrades, and rollback.
Archipelago will not duplicate GitWorkshop's repository browser, issue/PR,
fork, diff, relay, or GRASP behavior in private `source.*` RPC methods.
Primary references:
- https://ngit.dev/how-it-works
- https://github.com/DanConwayDev/gitworkshop
- https://gitworkshop.dev/
- https://nips.nostr.com/34
- https://docs.rs/crate/ngit/latest/source/README.md
- https://ngit.dev/grasp/
## Recommended Architecture
## Trust And Permissions
### Apps
- GitWorkshop runs as a static, read-only container behind the app gate.
- The iframe may request NIP-07 operations through `postMessage`; only the
exact launched frame and expected origin are accepted.
- `getPublicKey`, event signing, encryption, and decryption require explicit
dashboard consent. A remembered decision is scoped to node origin, app,
selected identity, and method.
- Contributor private keys never enter the GitWorkshop container.
- Browser-origin signing calls from direct high-port app origins are rejected;
they must pass through the dashboard consent bridge.
- Maintainer and release keys must not be placed on ordinary user nodes.
- Relay and GRASP data is untrusted. Canonical status is derived only from the
signed repository announcement and configured maintainer identities.
Create two first-party apps:
## Upstream Pin And Redistribution Decision
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
issues/PRs, opening branches, and submitting PR events.
The development image currently pins GitWorkshop commit
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da` (2026-07-28). The integration patch
only adds base-path support and the Archipelago NIP-07 provider.
The `archipelago-source` app should depend on `ngit`. It can also recommend
Gitea for users who want a conventional local web Git UI, but Gitea should not
be the source of truth for public contribution permissions.
The pinned revision and current upstream `main` have no license file, the npm
package metadata declares no license, and GitHub reports no detected license.
An earlier project-site description of “MIT” is not a license grant bundled
with the code. On 2026-09-11 the Archipelago owner explicitly accepted the
risk of publishing the patched build without an upstream license. That removes
the project's internal release hold, but it does not turn the upstream code
into open-source software or grant downstream rights. The absence is disclosed
in `NOTICE` and the packaged `UPSTREAM.md`.
### Contributor Onboarding
Preferred follow-up: ask upstream to add an SPDX-recognized license file
(MIT if that remains their intent), then re-pin at or after that commit and add
GitWorkshop plus its copyright/license notice to Archipelago's `NOTICE` and
generated image inventory. A written grant that explicitly permits compiling,
modifying, and redistributing this app is an alternative, but is harder for
downstream users to audit. A public GitHub repository or permission to fork is
not sufficient redistribution permission. Production dependency-audit findings
must also be resolved or explicitly accepted before release.
When the user installs `archipelago-source` from the registry:
The release-preparation audit on 2026-09-09 ran `npm audit --omit=dev` against
the exact pinned commit and reported 4 high and 6 moderate advisories, with
fixes available for every affected package. The same commit remains upstream
`main`, so repinning alone does not resolve them. The final runtime image is
static nginx rather than Node, which makes the Hono server findings unlikely to
be runtime-reachable, but browser/runtime dependencies such as `fflate` and
React Router still require an upstream dependency update or an explicit,
written risk acceptance before registry publication.
1. Show a modal before first launch: "Contribute to Archipelago".
2. Explain that the app will use their Archipelago Nostr identity to clone and
sign contribution events.
3. Display the maintainer repository announcement, clone URL, maintainer npub,
and relay/GRASP endpoints.
4. Ask for consent to:
- fetch repository metadata from configured relays,
- clone source through `nostr://`,
- create local branches,
- sign NIP-34 issue/PR/comment events,
- push PR branches to approved GRASP servers.
5. Store approval per app origin, identity id, repository id, and relay set.
## Canonical Archipelago Repository
This should build on the existing NIP-07 app-launcher bridge, but use a more
specific permission scope than the generic sign-event approval.
The canonical announcement maintainer is
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg`.
The repository already contains a root MIT `LICENSE` and `CONTRIBUTING.md`;
contributors agree to license their contributions under that MIT License.
### Identity And Permissions
The user-facing Source app can ship for local evaluation before the canonical
Archipelago Nostr repository exists, but it must not pretend a placeholder is
canonical. Canonical launch requires:
Use four identity classes:
1. A sanitized public `archy` source repository.
2. An offline or tightly controlled maintainer identity.
3. A signed NIP-34 kind `30617` repository announcement.
4. At least one Archipelago-operated relay/GRASP endpoint and one independent
compatible mirror.
5. Tested `nostr://` clone, proposal, update, review, merge-status, server-loss,
and recovery flows.
6. A GitWorkshop link/configuration that opens the verified `archy` repository.
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
signs the canonical kind `30617` repo announcement and status/merge events.
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
allowed to advance canonical refs and publish valid merged/applied status.
- `archipelago-build`: release automation key for signed release artifacts and
CI status events. It must not have merge authority.
- `contributor`: user node or app-specific identity used for PRs, issues, and
comments.
The existing HTTP Git remote remains a fallback until those drills pass.
Contributor rights:
## Delivery Milestones
- Clone the repository.
- Open issues.
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
- Publish NIP-34 PR/update/comment events.
- Rebase and update their own PR branch.
- Run local validation and attach status evidence.
### 1. Plan And Protocol Review — complete
Contributor restrictions:
- Confirmed NIP-34/ngit/GRASP as the interoperability layer.
- Defined contributor, maintainer, build, and release trust boundaries.
- Confirmed that installation must ultimately come from the Archipelago app
registry and include a path to the upstream/source code.
- Cannot update `refs/heads/main` or release branches in canonical state.
- Cannot publish maintainer-valid merge/applied status.
- Cannot alter the canonical repository announcement.
- Cannot publish release catalog signatures.
### 2. Runtime Feasibility — complete
Maintainer rights:
- Validated pinned `ngit` and `git-remote-nostr` binaries on supported node
architectures.
- Exercised public `nostr://` discovery/clone behavior.
- Established that app lifecycle dependencies do not share executables or
filesystems, avoiding an invalid two-container CLI design.
- Publish/update the canonical repo announcement.
- Publish canonical `refs/heads/main` state.
- Mark PRs merged/closed/draft via NIP-34 status events.
- Sign release tags and catalog updates.
These CLI checks remain useful for canonical repository operations and release
validation; they are not a reason to build a second browser client.
Fork rights:
### 3. Node Integration Foundation — complete
- Any contributor can create their own NIP-34 kind `30617` repository
announcement for a fork.
- Fork announcements should use the NIP-34 `u` tag to point back to the
canonical `archy` repository.
- The source app should make forking a first-class path: "Fork on Nostr", clone
the fork locally, push branches to the contributor's GRASP list, and open PRs
back to canonical Archipelago when they want review.
- Forks can have their own maintainer npubs, relays, policies, and release
cadence, but the app should clearly label them as forks unless signed by the
canonical maintainer set.
- Added the installable app manifest, catalog metadata, icon, and port
reservation.
- Added identity selection and a generic consent-gated NIP-07 bridge.
- Kept signing secrets out of the app container.
The GRASP server policy should enforce this by accepting pushes to maintainer
refs only when backed by signed maintainer state, while allowing contributor PR
refs from their own npubs.
### 4. GitWorkshop Pivot — complete on the development node
## Repository Layout
- Replace the prototype Source UI and all private `source.*` APIs with the
pinned upstream GitWorkshop build.
- Mount it below `/app/archipelago-source/` and proxy to the authenticated app
gate, eliminating hard-coded address and high-port launch behavior.
- Validate upstream base-path routing, static assets, browser refresh/deep
links, NIP-07 requests, container hardening, and install/restart behavior.
- Deploy the resulting daemon, dashboard, and app only on this development
node, then hand it to the owner for UAT.
Canonical repo announcement:
The pinned integration patch applies cleanly to a fresh upstream checkout. The
upstream unit suite passes 152 tests, Archipelago's full frontend suite passes
1,091 tests across 137 files, the production dashboard build and Android UAT
lint/build pass, and the manifest passes all 16 validators. The read-only,
capability-free container passes health, asset, manifest, and base-path checks.
The live same-origin route reaches the authenticated app gate through the
node's loopback, LAN, Tailscale, and FIPS addresses. A rollback snapshot is at
`/var/backups/archipelago/pre-uat-fixes-20260908-1140` on the development node.
- repo id: `archy`
- display name: `Archipelago`
- clone URLs:
- `nostr://<maintainer-npub>/<relay-hint>/archy`
- `https://<grasp-host>/<maintainer-npub>/archy.git`
- relays:
- Archipelago-operated relay
- at least two public Nostr relays that support the event load
- GRASP servers:
- Archipelago-operated GRASP instance
- one public GRASP-compatible mirror
### 5. Owner UAT — accepted, final release-candidate retest required
Keep the existing HTTP Git remote as a mirror during launch. The docs can
present `nostr://` as the preferred contribution path once the workflow is
proven.
The owner exercised the corrected node deployment and requested release on
2026-09-11. A short final retest remains required after the 0.5.32 release-channel
Companion APK and canonical Archipelago repository deep-link replace their UAT
counterparts; those two release inputs were not present in the accepted build.
## UI Requirements
For companion testing, the node hosts a local-only Archipelago Companion
`0.5.32-uat` at `/packages/archipelago-companion-0.5.32-uat.apk`. It uses the
separate package ID `com.archipelago.app.uat`, installs beside the existing
companion, and includes the native WebView launch plus Android's native node-CA
installer. Its SHA-256 is
`8924d7ba3a013e0db09a5f1e72c21de7886e5e21ed1b2e31d585495183191fe7`.
The production companion download remains unchanged.
The source app should provide:
Owner UAT should cover:
- A first-run contribution modal with a real Archipelago source graphic, not a
generic text-only dialog.
- Current clone status and local path.
- Branch list, changed files, commit form, and push/open-PR flow.
- PR inbox, issue list, maintainer status, and relay health.
- Explicit identity indicator showing which npub will sign events.
- A merge rights indicator that clearly says contributors can propose changes
but cannot merge them.
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
then offers "Open PR to Archipelago" from any fork branch.
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
metadata or server-side account names.
- Links to container docs, deployment docs, manifest spec, and open-source
readiness tasks.
1. Install/reinstall GitWorkshop from the local App Store and open it from the
App Store, Apps screen, and Source banner. Confirm Discover shows Popular
Apps first, the banner after two desktop rows, and the remaining catalog
under All Apps; confirm the GitWorkshop mark is no longer the old icon.
2. Confirm it opens as a top-level page in Companion's native in-app browser,
not a dashboard iframe, and loads without a blank or "webpage unavailable"
screen. Confirm Back and Close return through the Companion UI correctly.
3. Select a node identity, exercise `getPublicKey` and signing prompts, verify
the contained consent surface, short identity-circle loader, success/error,
allow/deny/remember behavior, then change identity and confirm consent is
requested again. Repeat this flow inside the Companion WebView.
4. Edit a Nostr identity and confirm the identity-specific success screen shows
the saved identity, relay coverage, event ID, copy action, and honest partial
publish warning when a relay does not accept the update.
5. Browse a known NIP-34 repository and exercise the contribution actions that
GitWorkshop exposes without granting direct merge or release authority.
6. From Companion settings, choose **Download this node's certificate** and
confirm Android opens the system CA-install prompt for this node. Confirm
the ordinary browser link still downloads the `.crt` file.
7. Repeat launch through whichever of LAN, Tailscale, FIPS, DNS, IPv4, or IPv6
is available; the app must follow the dashboard origin rather than a stored
address. A raw numeric address works over HTTP; for HTTPS over Tailscale use
the node's MagicDNS hostname because the certificate is issued to that name,
not to the numeric Tailscale address.
## Backend Work
UAT follow-up on 2026-09-08 found three integration defects: the mounted gate
used root-relative form/assets and returned nginx 405 in a fresh mobile
browser; silent signer requests flashed the full-screen broker frame in the
Companion WebView; and IndeeHub reloaded while the signer's success surface was
still closing, leaving Android WebView blank. The fixes are implemented with a
validated forwarded mount, consent-driven broker visibility, and a coordinated
post-auth reload plus native page-commit fallback. These items remain pending
owner retest on the development node; their implementation is not UAT
acceptance. The fixes were deployed locally on 2026-09-08. Live engineering
checks confirm that mounted gate pages and assets retain the app prefix, gate
POSTs return the application's 401 response instead of nginx 405 over HTTP and
LAN HTTPS, both apps are healthy, and the served provider and UAT APK match
their build hashes.
Add an RPC module for source contribution workflow:
A further Companion retest showed a black surface immediately after the first
identity selection even though authentication, reload, application data, and
`/api/auth/me` all completed successfully. The common cause was the Android
Chromium compositor retaining the hidden broker iframe's last full-screen black
canvas. The broker route now has a genuinely transparent document, and hidden
brokers stay loaded as a non-interactive 1px surface parked off-screen so the
identity choice and immediately following sign request share one broker.
Companion covers an expected authentication navigation with its branded loader
until the app commits a new frame. This is deployed in `0.5.32-uat` and
remains pending owner visual retest.
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
maintainer npubs, and local clone state.
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
- `source.clone`: clones or updates the local source checkout.
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
- `source.commit`: creates a local commit from selected files.
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
remote.
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
- `source.update-pr`: updates the branch and publishes kind `1619`.
- `source.issue`: publishes a kind `1621` event.
### 6. Canonical Nostr Launch — pending
Backend must shell out through a narrow command wrapper, never arbitrary user
commands. The wrapper should set an isolated working tree under
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
deny operations outside that path.
- Publish and configure the signed `archy` kind `30617` announcement.
- Bring up and test the chosen relays and GRASP servers.
- Deep-link/configure GitWorkshop to the verified repository.
- Run the real-node proposal and recovery drills listed above.
## Security Model
### 7. Release — pending canonical publication and signing gates
- Never expose maintainer private keys to an Archipelago node.
- Prefer app-specific contributor identities over the node's default identity.
- Require per-action consent for first PR push, issue creation, and signing any
event that tags the canonical repository.
- Pin the canonical maintainer npub in the app manifest and backend config.
- Keep the canonical merge-maintainer allow list signed by the
`archipelago-maintainer` key; never infer merge rights from GRASP server
accounts.
- Verify the canonical kind `30617` event signature before displaying clone
instructions.
- Treat GRASP servers as untrusted storage; verify Git refs against signed
Nostr state.
- Do not use destructive git operations from the UI without an explicit modal.
- Store local clones and generated patches outside app container writable roots
unless the user exports them.
Only after engineering tests, the final release-candidate retest, canonical
launch tests, and dependency-risk disposition may the team:
## MVP
- build and publish a production multi-architecture app image;
- sign/update the production app-registry entry;
- include the integration in an OTA or ISO;
- add release notes and migration/rollback instructions.
1. Package `ngit` as a first-party app.
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
3. Publish sanitized fresh-history `archy` through `ngit init`.
4. Add a simple `archipelago-source` app that clones source and links out to the
preferred Nostr Git browser.
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
6. Allow issues and PR branch submission from contributor npubs.
7. Add a one-click fork flow that publishes a contributor-owned fork
announcement referencing canonical Archipelago.
8. Keep maintainer merge/status publication manual.
The production companion signing path also needs an explicit release decision.
The current branch omits the shared debug keystore expected by
`scripts/publish-companion-apk.sh` (an older repository revision contains it),
while the local UAT key is intentionally unsuitable for public artifacts.
Before publishing, verify upgrade compatibility against the already-distributed
companion's signing certificate and stage only the intended production-signed
APK.
## Later
## Completed Next-OTA Follow-ups
- Native PR review UI with file diffs and inline comments.
- CI status events signed by the build identity.
- FIPS-first source sync between trusted Archipelago nodes.
- Private prerelease repositories using NIP-42 allow lists and/or protected
events if the ecosystem support is mature enough.
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
rotation events.
- The container doctor detects a missing rootless Podman `pasta` listener and
restarts only the affected container, including the intermittent Nginx Proxy
Manager port 8081 case. TCP and UDP bindings are checked independently.
- The node-certificate UI contains the approved macOS, iOS/iPadOS, Windows,
Android, Linux, browser restart, DNS, and symptom/cause guidance, while the
Companion hands the downloaded node CA to Android's system installer.
## Open Questions
## Open Decisions Before Canonical Launch
- Which maintainer npub should become canonical for `archy`?
- Should contributor identities be node-default or app-specific by default?
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
NIP-34/GRASP-compatible relay?
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
browser links for review while keeping signing/submission native?
- What exact license and contribution certificate should contributors accept
before submitting PR events?
- Which Archipelago-operated and independent relay/GRASP endpoints are used?
- Does the owner accept the recorded 4 high and 6 moderate GitWorkshop npm
advisories for this release, or must the integration patch update them first?
- Will upstream add an explicit GitWorkshop license as a post-release
clarification for downstream users?
@@ -262,7 +262,12 @@ ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
# were added to the Dockerfile below — the cache condition never looked at
# the recipe. Hash the rootfs-defining region of this script; any edit to it
# forces a rebuild. `--rebuild` still forces one unconditionally.
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
RECIPE_HASH=$(
{
sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0"
cat "$SCRIPT_DIR/../configs/install-ngit.sh"
} | sha256sum | cut -d' ' -f1
)
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
echo " Using Docker to create Debian root filesystem..."
@@ -451,6 +456,13 @@ COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
# Install the pinned Nostr Git runtime. The installer verifies the release
# archive before copying ngit and git-remote-nostr into /usr/bin.
COPY install-ngit.sh /tmp/install-ngit.sh
RUN chmod 0755 /tmp/install-ngit.sh && \
/tmp/install-ngit.sh && \
rm /tmp/install-ngit.sh
# Configure locale
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
@@ -796,6 +808,11 @@ NGINXCONF
echo " Using nostr-relay-config.toml from configs/"
fi
if [ -f "$SCRIPT_DIR/../configs/install-ngit.sh" ]; then
cp "$SCRIPT_DIR/../configs/install-ngit.sh" "$WORK_DIR/install-ngit.sh"
echo " Using pinned ngit installer from configs/"
fi
# Copy WireGuard helper script (privileged peer management)
if [ -f "$SCRIPT_DIR/../../scripts/archipelago-wg" ]; then
cp "$SCRIPT_DIR/../../scripts/archipelago-wg" "$WORK_DIR/archipelago-wg"
@@ -1584,7 +1601,7 @@ IMAGES_CAPTURED_FROM_SERVER=0
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
echo " Capturing container images from live server ($DEV_SERVER)..."
# Patterns match against `podman images` repository names (not container names)
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager adguard"
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager"
REMOTE_TMP="/tmp/archipelago-image-capture-$$"
SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true
for p in $SAVED_LIST; do
@@ -1631,7 +1648,6 @@ ${PHOTOPRISM_IMAGE} photoprism.tar
${NEXTCLOUD_IMAGE} nextcloud.tar
${NPM_IMAGE} nginx-proxy-manager.tar
${ONLYOFFICE_IMAGE} onlyoffice.tar
${ADGUARDHOME_IMAGE} adguardhome.tar
"
# Pull and save each image (force target arch) only if not already present
@@ -6,7 +6,7 @@ After=archipelago.service
Type=oneshot
# Runs as root: needs to kill orphaned conmon processes, fix permissions
User=root
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
ExecStart=/opt/archipelago/scripts/container-doctor.sh --local
TimeoutStartSec=300
StandardOutput=journal
StandardError=journal
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# Install the exact ngit runtime validated for Archipelago source hosting.
#
# The GitHub release archive contains both `ngit` and `git-remote-nostr`.
# Keep version, filenames, and SHA-256 values together so image builds and
# OTA updates cannot silently resolve a newer upstream release.
set -eu
NGIT_VERSION="2.6.3"
NGIT_RELEASE_BASE="https://github.com/DanConwayDev/ngit-cli/releases/download/v${NGIT_VERSION}"
X86_64_ASSET="ngit-v${NGIT_VERSION}-x86_64-unknown-linux-gnu.2.17.tar.gz"
X86_64_SHA256="81dd9b6a11a4a0feb946e56f55d557dc24075f1dcdda00ac35f9fd01920b9779"
AARCH64_ASSET="ngit-v${NGIT_VERSION}-aarch64-unknown-linux-gnu.2.17.tar.gz"
AARCH64_SHA256="e9d9437b7574e729b5a5d5cd800ebd668b73e6eb5c5859d52f83114c2f4b08b8"
install_root="${ARCHIPELAGO_NGIT_INSTALL_ROOT:-}"
case "$install_root" in
""|/*) ;;
*)
echo "ARCHIPELAGO_NGIT_INSTALL_ROOT must be empty or absolute" >&2
exit 2
;;
esac
install_dir="${install_root}/usr/bin"
ngit_bin="${install_dir}/ngit"
helper_bin="${install_dir}/git-remote-nostr"
if [ -x "$ngit_bin" ] && [ -x "$helper_bin" ] && \
[ "$($ngit_bin --version 2>/dev/null || true)" = "ngit ${NGIT_VERSION}" ] && \
[ "$($helper_bin --version 2>/dev/null || true)" = "v${NGIT_VERSION}" ]; then
echo "ngit ${NGIT_VERSION} already installed"
exit 0
fi
machine="${ARCHIPELAGO_NGIT_ARCH:-$(uname -m)}"
case "$machine" in
x86_64|amd64)
asset="$X86_64_ASSET"
expected_sha256="$X86_64_SHA256"
;;
aarch64|arm64)
asset="$AARCH64_ASSET"
expected_sha256="$AARCH64_SHA256"
;;
*)
echo "Unsupported ngit architecture: $machine" >&2
exit 2
;;
esac
download_dir=$(mktemp -d -t archipelago-ngit.XXXXXX)
cleanup() {
rm -rf -- "$download_dir"
}
trap cleanup EXIT HUP INT TERM
archive="$download_dir/$asset"
curl --fail --silent --show-error --location \
--proto '=https' --tlsv1.2 \
--retry 3 --connect-timeout 20 \
--output "$archive" "$NGIT_RELEASE_BASE/$asset"
actual_sha256=$(sha256sum "$archive" | awk '{print $1}')
if [ "$actual_sha256" != "$expected_sha256" ]; then
echo "ngit archive checksum mismatch for $asset" >&2
echo "expected: $expected_sha256" >&2
echo "actual: $actual_sha256" >&2
exit 1
fi
# Extract only the two expected top-level files. Unexpected archive content is
# never copied into the host filesystem.
tar -xzf "$archive" -C "$download_dir" ngit git-remote-nostr
mkdir -p "$install_dir"
install -m 0755 "$download_dir/ngit" "$ngit_bin"
install -m 0755 "$download_dir/git-remote-nostr" "$helper_bin"
[ "$($ngit_bin --version)" = "ngit ${NGIT_VERSION}" ]
[ "$($helper_bin --version)" = "v${NGIT_VERSION}" ]
echo "installed ngit ${NGIT_VERSION} for $machine"
+41 -2
View File
@@ -52,6 +52,17 @@ server {
try_files $uri =404;
}
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
# companion WebView. This document alone may be framed by another port on
# the same node; signing RPCs still require an authenticated node session.
location = /nostr-signer {
try_files /index.html =404;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' http://$host:* https://$host:*; base-uri 'none'; form-action 'none';" always;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
#
# /aiui/-scoped CSP (AIUI-04, D-19 unaffected — this is a build-time/
@@ -691,13 +702,32 @@ server {
sub_filter "src='/" "src='/app/botfights/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script><script>window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="arcade-input"&&d.key){var t=d.action==="up"?"keyup":"keydown";document.dispatchEvent(new KeyboardEvent(t,{key:d.key,bubbles:true}))}})</script></head>';
}
# GitWorkshop follows the dashboard origin so every supported ingress
# works without separately publishing an app port. The app gate on
# 127.0.0.2 preserves session authentication before forwarding to the
# loopback-only container.
location /app/archipelago-source/ {
proxy_pass http://127.0.0.2:8337/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_request_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1G;
client_max_body_size 10G;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Override parent add_header to allow iframe embedding
@@ -1037,6 +1067,16 @@ server {
return 504 '{"error":{"code":"BACKEND_TIMEOUT","message":"Service did not respond in time"}}';
}
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
# companion WebView. Keep this aligned with the HTTP server block.
location = /nostr-signer {
try_files /index.html =404;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' http://$host:* https://$host:*; base-uri 'none'; form-action 'none';" always;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
#
# /aiui/-scoped CSP — see the HTTP server block above for the full
@@ -1479,4 +1519,3 @@ server {
proxy_read_timeout 86400s;
}
}
@@ -33,9 +33,27 @@ location /app/uptime-kuma/ {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
# GitWorkshop follows the dashboard origin; the app gate keeps the route
# session-authenticated before it reaches the loopback-only container.
location /app/archipelago-source/ {
proxy_pass http://127.0.0.2:8337/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
proxy_request_buffering off;
client_max_body_size 10G;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { expect, test } from '@playwright/test'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
test('mobile Chromium returns to the live app after the signer is hidden', async ({ context, page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await context.route('**/*', async (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/nostr-provider.js') {
await route.fulfill({ contentType: 'application/javascript', body: providerSource })
return
}
if (url.port === '' && url.pathname === '/nostr-signer') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><body style="margin:0;background:#111827">
<script>
addEventListener('message', (event) => {
if (event.data && event.data.type === 'archipelago:signer-init') {
parent.postMessage({ type: 'archipelago:signer-show' }, event.origin)
setTimeout(() => {
parent.postMessage({
type: 'archipelago:signer-identity',
identity: { nostr_pubkey: 'browser-handoff-key' },
}, event.origin)
parent.postMessage({ type: 'archipelago:signer-hide' }, event.origin)
}, 50)
}
})
parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
</script>
</body></html>`,
})
return
}
if (url.port === '7778') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><head><title>IndeedHub</title>
<script src="/nostr-provider.js" data-no-nip98></script>
<style>html,body{margin:0;background:#166534}#app{height:100vh;color:white;display:grid;place-items:center}</style>
</head><body><main id="app">app-ready</main><script>
addEventListener('load', () => {
archipelagoNostr.onIdentitySelected((identity) => {
document.querySelector('#app').textContent = 'signed-in:' + identity.nostr_pubkey
})
})
</script></body></html>`,
})
return
}
await route.abort()
})
await page.goto('http://app.test:7778/')
await expect(page.locator('#app')).toHaveText('signed-in:browser-handoff-key')
const broker = page.locator('#archipelago-nostr-signer')
await expect(broker).toHaveCount(1)
await expect(broker).toHaveCSS('width', '1px')
await expect(broker).toHaveCSS('height', '1px')
await expect(broker).toHaveCSS('opacity', '0')
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(22, 101, 52)')
expect(await page.evaluate(() => document.elementFromPoint(195, 422)?.id)).toBe('app')
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.11-alpha",
"version": "1.8.15-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.11-alpha",
"version": "1.8.15-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.11-alpha",
"version": "1.8.15-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+3
View File
@@ -13,6 +13,9 @@ export default defineConfig({
screenshot: 'only-on-failure',
trace: 'off',
ignoreHTTPSErrors: true,
launchOptions: process.env.ARCHY_CHROMIUM_EXECUTABLE
? { executablePath: process.env.ARCHY_CHROMIUM_EXECUTABLE }
: undefined,
},
projects: [
{
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title id="title">GitWorkshop</title>
<!-- Official GitWorkshop mark from pinned upstream commit dc36db6, inset to
match Archipelago's app-icon safe area. The versioned filename also
prevents an older Source prototype icon surviving in browser caches. -->
<g transform="translate(13 13) scale(1.59375)">
<defs>
<clipPath id="gitworkshop-clip-dc36db6">
<rect width="64" height="64" rx="16" fill="#fff"/>
</clipPath>
</defs>
<g clip-path="url(#gitworkshop-clip-dc36db6)">
<rect width="64" height="64" rx="16" fill="#9333EA"/>
<path fill="#fff" fill-rule="evenodd" d="M14.999 34.961v-17.96h4v17.96c0 3.172 1.492 6.152 4.015 8.036L28.377 47a14.026 14.026 0 0 1 5.622 11.24V64h-4v-5.76c0-3.17-1.492-6.15-4.015-8.035l-5.363-4.004a14.027 14.027 0 0 1-5.622-11.24Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M48.999 34.961v-17.96h-4v17.96c0 3.172-1.492 6.152-4.015 8.036L35.62 47a14.027 14.027 0 0 0-5.622 11.24V64h4v-5.76c0-3.17 1.492-6.15 4.015-8.035l5.363-4.004A14.027 14.027 0 0 0 49 34.962Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M29.799 64.001V27.887h4.4V64h-4.4Z" clip-rule="evenodd"/>
<path fill="#fff" d="M40.999 18.001a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm-29.985 0a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm15.985 12a5 5 0 1 1 10 0 5 5 0 0 1-10 0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+37 -12
View File
@@ -9,19 +9,31 @@
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{
"id": "alby-hub",
"title": "Alby Hub",
@@ -247,6 +259,19 @@
},
"tier": "optional"
},
{
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
"author": "GitWorkshop contributors",
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{
"id": "grafana",
"title": "Grafana",
+351 -111
View File
@@ -1,160 +1,400 @@
/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* Provides window.nostr (NIP-07) for iframe apps.
* Auto sign-in: does NIP-98 auth directly then reloads so the app
* picks up the valid session. Shows a loading overlay during auth.
* In an Archipelago iframe, requests go directly to the parent dashboard.
* In a browser tab or companion WebView, a dashboard-origin signer frame
* supplies the same identity picker and consent UI. No opener is required,
* and private keys never leave the node backend.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
var pending = {}, nextId = 1;
var providerScript = document.currentScript;
var autoNip98 = !(providerScript && providerScript.hasAttribute('data-no-nip98'));
var embedded = window !== window.top;
var pending = {}, nextId = 1, queuedMessages = [];
var identitySelection = null;
var selectedIdentity = null, identitySubscribers = [];
var selectedPublicKey = null, selectedPublicKeyTimer = null;
var signerFrame = null, signerReady = embedded, signerInitialised = embedded;
var signerVisible = false, signerHideWaiters = [];
var appReady = embedded || document.readyState === 'complete';
function dashboardOrigin() {
var url = new URL(window.location.href);
url.port = '';
return url.origin;
}
function inferAppId() {
var configured = providerScript && providerScript.getAttribute('data-app-id');
if (configured) return configured;
var route = window.location.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i);
if (route) return route[1].toLowerCase();
var ports = { '7778': 'indeedhub', '8337': 'archipelago-source' };
return ports[window.location.port] || ('app-' + (window.location.port || 'dashboard'));
}
function sendToSignerFrame(message) {
if (!signerFrame || !signerFrame.contentWindow) return;
signerFrame.contentWindow.postMessage(message, dashboardOrigin());
}
function postToSigner(message) {
if (embedded) {
window.parent.postMessage(message, '*');
return;
}
if (!signerFrame) createSignerFrame();
// A loaded iframe is not yet an initialised signer. Requests that arrive
// while the host app is still booting must follow signer-init, otherwise
// the signer correctly rejects them because it has no app id/origin yet.
if (!signerReady || !signerInitialised || !signerFrame || !signerFrame.contentWindow) {
queuedMessages.push(message);
return;
}
sendToSignerFrame(message);
}
function setSignerVisible(visible) {
if (!signerFrame) return;
signerVisible = visible;
signerFrame.style.display = 'block';
signerFrame.style.visibility = 'visible';
signerFrame.style.pointerEvents = visible ? 'auto' : 'none';
signerFrame.style.opacity = visible ? '1' : '0';
signerFrame.style.top = '0';
signerFrame.style.left = '0';
signerFrame.style.width = visible ? '100vw' : '1px';
signerFrame.style.height = visible ? '100vh' : '1px';
signerFrame.style.transform = visible ? 'none' : 'translate(-10000px, -10000px)';
signerFrame.setAttribute('aria-hidden', visible ? 'false' : 'true');
if (!visible && signerHideWaiters.length) {
var waiters = signerHideWaiters.splice(0);
waiters.forEach(function (resolve) { resolve(); });
}
}
// NIP-98 returns before the signer's short success animation has closed.
// Reloading an Android WebView while that topmost cross-origin frame is
// still visible can leave a blank compositor surface until the user reloads
// again. Let the broker finish and hide first, with a bounded fallback so a
// lost UI message can never prevent authentication from completing.
function waitForSignerToHide() {
if (embedded || !signerVisible) return Promise.resolve();
return new Promise(function (resolve) {
var settled = false;
function finish() {
if (settled) return;
settled = true;
resolve();
}
signerHideWaiters.push(finish);
setTimeout(finish, 1500);
});
}
function createSignerFrame() {
if (embedded || signerFrame) return;
signerFrame = document.createElement('iframe');
signerFrame.id = 'archipelago-nostr-signer';
signerFrame.title = 'Archipelago Nostr signer';
signerFrame.src = dashboardOrigin() + '/nostr-signer';
// Keep the broker document alive between requests, but park its compositor
// surface physically off-screen. Removing or display-hiding a full-screen
// cross-origin iframe can leave Android WebView (and some mobile Chromium
// builds) showing that stale black/grey surface until a manual refresh.
// A 1px off-screen frame cannot obscure the app and also avoids reloading
// the signer between getPublicKey/signEvent calls.
signerFrame.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;transform:translate(-10000px,-10000px);border:0;z-index:2147483647;background:transparent;display:block;visibility:visible;opacity:0;pointer-events:none;';
signerFrame.setAttribute('aria-hidden', 'true');
document.documentElement.appendChild(signerFrame);
}
function initialiseSignerWhenReady() {
if (embedded || signerInitialised || !signerReady || !appReady) return;
sendToSignerFrame({
type: 'archipelago:signer-init',
appId: inferAppId(),
appName: (document.title || 'App').replace(/\s*[|—-]\s*Archipelago\s*$/i, ''),
});
signerInitialised = true;
while (queuedMessages.length) sendToSignerFrame(queuedMessages.shift());
}
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
postToSigner({ type: 'nostr-request', id: id, method: method, params: params || {} });
setTimeout(function () {
if (pending[id]) {
pending[id].reject(new Error('NIP-07 timeout'));
delete pending[id];
}
}, 30000);
});
}
// Archipelago-aware apps can call this immediately before an explicit login
// action. Standard NIP-07 intentionally has no "choose account" method, so
// getPublicKey() alone cannot distinguish a fresh login from a routine signer
// call. Keeping this as an optional companion API preserves NIP-07 compatibility
// while allowing users to change their node identity when they log in again.
function selectIdentity() {
if (identitySelection) {
identitySelection.reject(new Error('A node identity choice is already open'));
clearTimeout(identitySelection.timer);
}
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
if (!identitySelection) return;
identitySelection = null;
reject(new Error('Identity selection timed out'));
}, 30000);
identitySelection = { resolve: resolve, reject: reject, timer: timer };
postToSigner({
type: embedded
? 'archipelago:identity:request'
: 'archipelago:signer-select-identity',
force: true,
});
});
}
function finishIdentitySelection(identity) {
// The identity picker is itself an explicit choice to disclose this key.
// Keep it briefly so the login library's immediately-following
// getPublicKey() does not depend on another cross-origin WebView round trip.
// This is deliberately one-shot and short-lived.
if (identity && typeof identity.nostr_pubkey === 'string' && identity.nostr_pubkey) {
selectedIdentity = { nostr_pubkey: identity.nostr_pubkey };
selectedPublicKey = identity.nostr_pubkey;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = setTimeout(function () {
selectedPublicKey = null;
selectedPublicKeyTimer = null;
}, 15000);
identitySubscribers.slice().forEach(function (subscriber) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
});
}
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.resolve(identity);
}
function cancelIdentitySelection() {
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.reject(new Error('Identity selection cancelled'));
}
function getPublicKey() {
// Most NIP-07 apps call getPublicKey directly from their login button. A
// live user activation lets the node offer account switching to those apps
// without making background account restoration reopen the picker. Apps
// with an async login flow should call archipelagoNostr.selectIdentity()
// explicitly; its result is consumed here so the picker is not shown twice.
if (selectedPublicKey) {
var publicKey = selectedPublicKey;
selectedPublicKey = null;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = null;
return Promise.resolve(publicKey);
}
if (navigator.userActivation && navigator.userActivation.isActive) {
return selectIdentity().then(function () {
return getPublicKey();
});
}
return request('getPublicKey');
}
// Framework components often mount just after the provider receives the
// eager first-launch identity. A sticky subscription prevents that choice
// from being lost between window.load and React/Vue effect registration.
function onIdentitySelected(subscriber) {
if (typeof subscriber !== 'function') {
throw new TypeError('Identity subscriber must be a function');
}
identitySubscribers.push(subscriber);
if (selectedIdentity) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
}
return function () {
identitySubscribers = identitySubscribers.filter(function (entry) {
return entry !== subscriber;
});
};
}
function getSelectedIdentity() {
return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey };
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
var validSource = embedded
? e.source === window.parent
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
if (!validSource || !e.data) return;
if (!embedded && e.data.type === 'archipelago:signer-ready') {
signerReady = true;
initialiseSignerWhenReady();
return;
}
if (!embedded && e.data.type === 'archipelago:signer-show') {
setSignerVisible(true);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-hide') {
setSignerVisible(false);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-identity') {
finishIdentitySelection(e.data.identity);
window.postMessage({
type: 'archipelago:identity',
nostr_pubkey: e.data.identity && e.data.identity.nostr_pubkey,
}, window.location.origin);
return;
}
if (embedded && e.data.type === 'archipelago:identity') {
finishIdentitySelection(e.data);
return;
}
if (e.data.type === 'archipelago:identity-cancelled' ||
e.data.type === 'archipelago:signer-identity-cancelled') {
cancelIdentitySelection();
return;
}
if (e.data.type !== 'nostr-response') return;
var handler = pending[e.data.id];
if (!handler) return;
delete pending[e.data.id];
e.data.error ? handler.reject(new Error(e.data.error)) : handler.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getPublicKey: getPublicKey,
signEvent: function (event) { return request('signEvent', { event: event }); },
sign: function (event) { return request('signEvent', { event: event }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
encrypt: function (pubkey, plaintext) { return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
nip44: {
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
encrypt: function (pubkey, plaintext) { return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
};
// --- Loading Overlay ---
var overlay = null;
window.archipelagoNostr = {
selectIdentity: selectIdentity,
onIdentitySelected: onIdentitySelected,
getSelectedIdentity: getSelectedIdentity,
};
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Direct NIP-98 Auth ---
// Optional direct NIP-98 session bootstrap for apps that use it. Signing
// itself is shown by the shared broker, so this deliberately adds no second
// full-screen loader inside the app.
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
var healthUrl = window.location.origin + '/api/nostr-auth/health';
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
var healthController = new AbortController();
var healthTimeout = setTimeout(function () { healthController.abort(); }, 3000);
var apiBase = '/api';
var healthUrl = window.location.origin + apiBase + '/nostr-auth/health';
var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session';
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
clearTimeout(healthTimeout);
if (!response.ok) throw new Error('Health ' + response.status);
return window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']],
});
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
var controller = new AbortController();
setTimeout(function () { controller.abort(); }, 10000);
return fetch(sessionUrl, {
method: 'POST',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
signal: controller.signal,
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
return res.json();
}).then(function (response) {
if (!response.ok) throw new Error('Auth failed: ' + response.status);
return response.json();
}).then(function (data) {
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
if (!data.accessToken) throw new Error('Authentication returned no access token');
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
return waitForSignerToHide().then(function () {
// Give WebView one paint after the iframe is hidden before replacing
// the document. The stored session is already durable at this point.
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
}).then(function () {
if (window.ArchipelagoSurface &&
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
window.ArchipelagoSurface.expectPageTransition();
}
window.location.reload();
});
}).catch(function (error) {
authDone = false;
var message = error && error.message ? error.message : String(error);
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', message);
});
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return;
if (e.source !== window && e.source !== window.parent) return;
var pubkey = e.data.nostr_pubkey;
if (!pubkey) return;
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
if (token && token.indexOf('mock-') === -1) return;
} catch (_) {}
setTimeout(function () { doNip98Auth(pubkey); }, 1500);
});
// Only identity-aware apps open the chooser eagerly. The provider is also
// injected into several ordinary app proxies; those stay untouched unless
// they actually invoke a NIP-07 method, which lazily creates the broker.
if (!embedded && ['indeedhub', 'nostrudel', 'archipelago-source'].indexOf(inferAppId()) !== -1) {
createSignerFrame();
}
// The provider is injected in <head>, before framework startup. Waiting for
// load makes the first-launch picker meaningful: React/Vue login listeners
// and account stores exist before a fast identity choice can be emitted.
if (!embedded && !appReady) {
window.addEventListener('load', function () {
appReady = true;
initialiseSignerWhenReady();
}, { once: true });
}
})();
Binary file not shown.
@@ -1,4 +1,4 @@
{
"versionName": "0.5.28",
"versionCode": 48
"versionName": "0.5.32",
"versionCode": 52
}
+7 -1
View File
@@ -1,5 +1,7 @@
<template>
<div id="app">
<RouterView v-if="isSignerBroker && isReady" />
<template v-else>
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
@@ -15,6 +17,7 @@
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<AppCredentialInterstitial />
<!-- Global toast notifications -->
<ToastStack />
@@ -88,11 +91,12 @@
</div>
</Transition>
</Teleport>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
@@ -100,6 +104,7 @@ import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import AppCredentialInterstitial from './components/AppCredentialInterstitial.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
@@ -240,6 +245,7 @@ function onKeyDown(e: KeyboardEvent) {
}
const route = useRoute()
const isSignerBroker = computed(() => route.meta.signerBroker === true)
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
@@ -0,0 +1,43 @@
<template>
<BaseModal
:show="store.credentialPrompt.show"
:title="store.credentialPrompt.title"
max-width="max-w-lg"
z-index="z-[4100]"
@close="store.cancelCredentialLaunch()"
>
<div v-if="store.credentialPrompt.loading" class="flex min-h-40 flex-col items-center justify-center text-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-orange-300" aria-hidden="true"></div>
<p class="mt-4 text-sm text-white/55">{{ store.credentialPrompt.description }}</p>
</div>
<template v-else>
<p v-if="store.credentialPrompt.description" class="-mt-1 mb-4 text-sm text-white/55">
{{ store.credentialPrompt.description }}
</p>
<div class="space-y-3">
<div v-for="credential in store.credentialPrompt.credentials" :key="credential.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="mb-1 flex items-center justify-between gap-3">
<span class="text-xs uppercase tracking-wide text-white/60">{{ credential.label }}</span>
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="store.copyCredential(credential.label, credential.value)">
{{ store.credentialPrompt.copied === credential.label ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="break-all font-mono text-sm text-white">{{ credential.value }}</p>
</div>
</div>
</template>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row">
<button type="button" class="w-full rounded-lg px-4 py-3 sm:flex-1 glass-button" @click="store.cancelCredentialLaunch()">Cancel</button>
<button v-if="!store.credentialPrompt.loading" type="button" class="w-full rounded-lg px-4 py-3 font-semibold sm:flex-1 glass-button" @click="store.continueCredentialLaunch()">Continue to app</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
const store = useAppLauncherStore()
</script>
+80 -20
View File
@@ -118,7 +118,22 @@
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div v-if="completedPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl max-h-[90%] overflow-y-auto">
<PaymentSuccessPane
:amount="completedPayment.amount"
:verb="completedPayment.verb"
:method-label="completedPayment.methodLabel"
:rows="completedPayment.rows"
:note="completedPayment.note"
@done="completedPayment = null"
/>
</div>
</div>
</Transition>
<Transition name="content-fade">
<div v-if="pendingPayment && !completedPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
@@ -162,30 +177,31 @@
</div>
</div>
</Transition>
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:identity-label="store.consentRequest?.identityLabel"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
:phase="store.consentPhase"
:error="store.consentError"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Nostr signing consent modal -->
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</template>
<script setup lang="ts">
@@ -194,6 +210,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -292,6 +309,11 @@ function onIdentitySelected(identity: SelectedIdentity) {
sendSelectedIdentity(identity)
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
/** Send a specific identity to the iframe */
async function sendSelectedIdentity(identity: SelectedIdentity) {
try {
@@ -330,6 +352,13 @@ function clearTimers() {
const pendingPayment = ref<PaymentRequest | null>(null)
const paymentProcessing = ref(false)
const paymentError = ref('')
const completedPayment = ref<{
amount: number
verb: string
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
const paymentOrigin = ref('')
function refreshIframe() {
@@ -471,7 +500,8 @@ function onMessage(e: MessageEvent) {
}
// Iframe app requests identity on demand
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
sendIdentityIfSupported()
if (e.data?.force === true) showIdentityPicker.value = true
else sendIdentityIfSupported()
}
// Wallet connect — app requests a payment
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
@@ -495,6 +525,7 @@ function handlePaymentRequest(e: MessageEvent) {
address: data.address,
}
paymentOrigin.value = e.origin
completedPayment.value = null
paymentError.value = ''
paymentProcessing.value = false
}
@@ -561,6 +592,34 @@ async function approvePayment() {
}
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
const rows: SuccessRow[] = []
if (typeof receipt.payment_hash === 'string' && receipt.payment_hash) {
rows.push({ label: 'Payment hash', value: receipt.payment_hash })
}
if (typeof receipt.txid === 'string' && receipt.txid) {
rows.push({ label: 'Transaction ID', value: receipt.txid })
}
if (typeof receipt.token === 'string' && receipt.token) {
rows.push({
label: 'Token sent to app',
value: receipt.token,
hint: 'The app received this token. Keep a copy until it confirms the payment.',
truncate: true,
})
}
if (typeof receipt.payment_request === 'string' && receipt.payment_request) {
rows.push({ label: 'Invoice sent to app', value: receipt.payment_request, truncate: true })
}
const createdInvoice = method === 'lightning' && !pay.invoice
completedPayment.value = {
amount: Number(receipt.amount_sats) || pay.amount_sats,
verb: createdInvoice ? 'READY' : 'SENT',
methodLabel: createdInvoice
? 'Lightning invoice created'
: method === 'lightning' ? 'Paid over Lightning' : method === 'ecash' ? 'Sent as Cashu' : 'Sent on-chain',
rows,
note: createdInvoice ? 'The invoice was returned to the requesting app.' : `Payment approved for ${store.title || 'the app'}.`,
}
pendingPayment.value = null
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Payment failed'
@@ -612,6 +671,7 @@ watch(
if (pendingPayment.value) {
rejectPayment()
}
completedPayment.value = null
}
}
)
@@ -143,6 +143,11 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
import {
companionRelease,
companionReleaseMarker,
loadCompanionRelease,
} from '@/composables/useCompanionRelease'
import { isCompanionApp } from '@/utils/openExternal'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { useServerStore } from '@/stores/server'
@@ -163,17 +168,7 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
// metadata (ships in the frontend beside the APK at /packages/), written by
// publish-companion-apk.sh from the same gradle config that built the APK.
// Best-effort: no file, no note.
const companionVersion = ref<{ versionName: string; versionCode: number } | null>(null)
async function loadCompanionVersion() {
try {
const res = await fetch('/packages/archipelago-companion.json', { cache: 'no-store' })
if (!res.ok) return
const meta = await res.json()
if (meta && typeof meta.versionName === 'string' && meta.versionName) {
companionVersion.value = { versionName: meta.versionName, versionCode: Number(meta.versionCode) || 0 }
}
} catch { /* metadata is a nicety — the download works without it */ }
}
const companionVersion = companionRelease
// Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract).
@@ -213,10 +208,13 @@ let calmTicker: ReturnType<typeof setInterval> | null = null
// it. Server management for connected companions lives in the NESMenu instead.
const IN_COMPANION_APP = isCompanionApp()
onMounted(() => {
onMounted(async () => {
if (IN_COMPANION_APP) return
// The prompt is remembered per APK build, not forever. A browser that saw
// 0.5.28 should be told once when this node begins serving 0.5.32.
await loadCompanionRelease()
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
if (localStorage.getItem(STORAGE_KEY) !== companionReleaseMarker(companionVersion.value)) {
setTimeout(maybeShow, BASE_DELAY_MS)
}
} catch {
@@ -267,7 +265,7 @@ watch(companionIntroRequested, (requested) => {
watch(visible, async (isVisible) => {
if (!isVisible) return
if (!companionVersion.value) void loadCompanionVersion()
if (!companionVersion.value) void loadCompanionRelease()
// Generate large and let CSS scale down — at 112px source a ~45-module QR
// is 2.5px/module, which camera decoders (the companion app included)
// routinely fail on. 512px keeps every module crisp.
@@ -454,7 +452,7 @@ function dismiss() {
visible.value = false
step.value = 'download'
try {
localStorage.setItem(STORAGE_KEY, '1')
localStorage.setItem(STORAGE_KEY, companionReleaseMarker(companionVersion.value))
} catch {
// ignore
}
@@ -0,0 +1,50 @@
<template>
<div class="py-3 text-center">
<NostrIdentityOrb class="mx-auto" size="large" state="success" aria-label="Nostr identity updated" />
<p class="mt-3 text-[10px] uppercase tracking-[0.24em] text-green-300/70">Identity saved</p>
<h2 id="profile-editor-title" class="mt-1 text-2xl font-bold tracking-wide text-white">IDENTITY UPDATED</h2>
<p class="mt-2 text-sm text-white/55">{{ identityName }} is updated on this node.</p>
<div class="mt-6 space-y-3 rounded-xl bg-white/5 p-4 text-left">
<div>
<p class="mb-1 text-xs text-white/45">Relay publication</p>
<p class="text-sm text-white/80">{{ relaySummary }}</p>
</div>
<div v-if="eventId">
<p class="mb-1 text-xs text-white/45">Nostr event ID</p>
<div class="flex items-center gap-2">
<p class="min-w-0 flex-1 break-all font-mono text-xs text-white/75">{{ eventId }}</p>
<CopyButton class="shrink-0" :value="eventId" />
</div>
</div>
<p v-if="relayNote" class="text-xs leading-relaxed text-amber-200/70">{{ relayNote }}</p>
</div>
<div class="mt-6 flex gap-3">
<button type="button" class="glass-button flex-1 rounded-xl px-4 py-3 text-sm font-medium" @click="$emit('again')">Keep editing</button>
<button type="button" class="glass-button glass-button-warning flex-1 rounded-xl px-4 py-3 text-sm font-semibold" @click="$emit('done')">Done</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CopyButton from '@/components/CopyButton.vue'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
const props = withDefaults(defineProps<{
identityName: string
eventId?: string
accepted?: number
attempted?: number
relayNote?: string
}>(), { eventId: '', accepted: 0, attempted: 0, relayNote: '' })
defineEmits<{ again: []; done: [] }>()
const relaySummary = computed(() => {
if (!props.attempted) return 'Saved locally; no relay publication was attempted.'
if (props.accepted === props.attempted) return `Published to all ${props.attempted} configured relays.`
return `Published to ${props.accepted}/${props.attempted} configured relays.`
})
</script>
@@ -0,0 +1,96 @@
<template>
<div
class="nostr-orb"
:class="[`nostr-orb-${size}`, `nostr-orb-${state}`]"
role="img"
:aria-label="ariaLabel"
>
<div class="nostr-orb-ring" aria-hidden="true">
<span
v-for="(_, i) in 48"
:key="i"
class="nostr-orb-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<div class="nostr-orb-border" aria-hidden="true">
<div class="nostr-orb-inner">
<svg viewBox="0 0 122.88 88.39" xmlns="http://www.w3.org/2000/svg" class="nostr-orb-mark">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
<div v-if="state === 'success'" class="nostr-orb-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="m5 12 4 4L19 6"/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
size?: 'small' | 'medium' | 'large'
state?: 'idle' | 'loading' | 'success'
ariaLabel?: string
}>(), {
size: 'large',
state: 'idle',
ariaLabel: 'Nostr identity',
})
</script>
<style scoped>
.nostr-orb {
--orb-size: 148px;
--disc-size: 104px;
--mark-width: 42px;
--ring-radius: 60px;
--bar-height: 14px;
position: relative;
width: var(--orb-size);
height: var(--orb-size);
flex: 0 0 auto;
}
.nostr-orb-medium { --orb-size: 112px; --disc-size: 78px; --mark-width: 32px; --ring-radius: 45px; --bar-height: 11px; }
.nostr-orb-small { --orb-size: 80px; --disc-size: 56px; --mark-width: 23px; --ring-radius: 32px; --bar-height: 8px; }
.nostr-orb-ring { position: absolute; inset: 0; pointer-events: none; }
.nostr-orb-segment {
position: absolute;
left: 50%; top: 50%; width: 2.5px; height: var(--bar-height);
margin-left: -1.25px; margin-top: calc(var(--bar-height) / -2);
border-radius: 2px;
background: linear-gradient(to bottom, rgba(250,250,250,.45), rgba(250,250,250,.06));
transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1));
animation: nostr-orb-pulse 4s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 20ms);
}
.nostr-orb-loading .nostr-orb-segment { animation-duration: 1.35s; }
.nostr-orb-success .nostr-orb-segment { background: linear-gradient(to bottom, rgba(74,222,128,.75), rgba(74,222,128,.08)); animation-duration: 2.4s; }
.nostr-orb-border {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: var(--disc-size); height: var(--disc-size); border-radius: 9999px; padding: 3px;
background: linear-gradient(135deg, rgba(255,255,255,.6), rgba(0,0,0,.8));
box-shadow: 0 8px 24px rgba(0,0,0,.5); filter: drop-shadow(0 0 24px rgba(255,255,255,.08));
}
.nostr-orb-inner { width: 100%; height: 100%; border-radius: inherit; background: #000; display: flex; align-items: center; justify-content: center; }
.nostr-orb-mark { width: var(--mark-width); height: auto; filter: drop-shadow(0 0 12px rgba(250,250,250,.12)); animation: nostr-orb-breathe 4s ease-in-out infinite; }
.nostr-orb-loading .nostr-orb-mark { animation-duration: 1.5s; }
.nostr-orb-check {
position: absolute; right: 8%; bottom: 8%; width: 27%; height: 27%; border-radius: 9999px;
display: flex; align-items: center; justify-content: center; color: #dcfce7;
background: #16a34a; border: 2px solid rgba(255,255,255,.8); box-shadow: 0 4px 18px rgba(22,163,74,.45);
}
.nostr-orb-check svg { width: 65%; height: 65%; }
@keyframes nostr-orb-pulse {
0%, 100% { opacity: .15; transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1)) scaleY(.45); }
45%, 60% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1)) scaleY(1.15); }
}
@keyframes nostr-orb-breathe {
0%, 100% { opacity: .72; transform: scale(1); }
50% { opacity: 1; transform: scale(1.08); filter: drop-shadow(0 0 20px rgba(250,250,250,.22)); }
}
@media (prefers-reduced-motion: reduce) {
.nostr-orb-segment, .nostr-orb-mark { animation: none; }
}
</style>
+60 -147
View File
@@ -1,13 +1,10 @@
<template>
<Teleport to="body">
<Transition name="identity-picker">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop — frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<Transition name="identity-picker">
<div
v-if="show"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6"
@click.self="$emit('cancel')"
>
<!-- Main panel -->
<div
@@ -16,37 +13,22 @@
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
class="identity-picker-card glass-card relative z-10 w-full max-w-md overflow-y-auto p-5 sm:p-6"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
<div class="relative mb-6 flex flex-col items-center">
<div class="nostr-hero">
<!-- Radial viz segments — exact screensaver pattern, 48 bars, #FAFAFA -->
<div class="nostr-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="nostr-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<!-- Glass disc — exact logo-gradient-border from screensaver -->
<div class="nostr-glass-border">
<div class="nostr-glass-inner">
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
<div class="mb-5 flex items-center gap-3">
<NostrIdentityOrb size="small" />
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-[0.2em] text-white/35">Nostr identity</p>
<h2 class="mt-1 text-xl font-semibold text-white">Choose an identity</h2>
<p class="mt-1 truncate text-xs text-white/45">{{ appName }}</p>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
<button type="button" class="rounded-lg p-2 text-white/55 transition-colors hover:bg-white/10 hover:text-white" aria-label="Close" @click="$emit('cancel')">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div class="space-y-2" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
@@ -55,13 +37,21 @@
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<div v-else-if="identities.length === 0" class="text-center py-8">
<div v-else-if="loadError" class="text-center py-8">
<p class="text-red-300/80 text-sm">Couldn&rsquo;t load identities.</p>
<p class="text-white/35 text-xs mt-1">{{ loadError }}</p>
<button type="button" class="glass-button mt-4 rounded-lg px-4 py-2 text-xs text-white/70" @click="loadIdentities">
Try again
</button>
</div>
<div v-else-if="userIdentities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<button
v-for="identity in identities"
v-for="identity in userIdentities"
:key="identity.id"
type="button"
role="radio"
@@ -117,19 +107,19 @@
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
<p class="mt-3 text-center text-[10px] tracking-widest text-white/20">
NIP-07 &middot; SECP256K1 &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { rpcClient } from '@/api/rpc-client'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
interface Identity {
id: string
@@ -140,6 +130,7 @@ interface Identity {
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
is_node?: boolean
}
const props = defineProps<{
@@ -156,11 +147,25 @@ const modalRef = ref<HTMLElement | null>(null)
const identities = ref<Identity[]>([])
const selectedId = ref<string | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
// The node key authenticates the appliance itself (mesh/discovery and other
// platform operations), not the person's public profile. The API marks it
// explicitly; keep a defensive name/purpose fallback for older nodes that do
// not send is_node yet.
const userIdentities = computed(() => identities.value.filter(identity =>
// `node-<pubkey>` is the deterministic id used by older node APIs before
// the explicit is_node marker was added. Keep this fallback so an older
// backend can never expose the appliance key as a profile choice.
!identity.is_node
&& !identity.id.trim().toLowerCase().startsWith('node-')
&& identity.name.trim().toLowerCase() !== 'node'
))
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value)
const selected = userIdentities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null
})
@@ -174,21 +179,25 @@ onMounted(() => {
async function loadIdentities() {
loading.value = true
loadError.value = null
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|| userIdentities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id
} catch {
} catch (error) {
identities.value = []
loadError.value = error instanceof Error
? error.message
: 'Check the node connection and try again.'
} finally {
loading.value = false
}
}
function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value)
const selected = userIdentities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected)
}
@@ -207,116 +216,20 @@ function avatarClasses(purpose: string): string {
</script>
<style scoped>
/* ── Hero container ── */
.nostr-hero {
position: relative;
width: 148px;
height: 148px;
}
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
.nostr-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.nostr-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
@keyframes seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
/* ── Glass disc — exact screensaver logo-gradient-border ── */
.nostr-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.nostr-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Cinema icon — breathing glow ── */
.nostr-cinema-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
animation: cinema-breathe 4s ease-in-out infinite;
}
@keyframes cinema-breathe {
0%, 100% {
opacity: 0.7;
transform: scale(1);
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
}
50% {
opacity: 1;
transform: scale(1.08);
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
}
}
.identity-picker-card { max-height: min(90%, 680px); }
/* ── Modal transitions ── */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.4s ease;
}
.identity-picker-enter-active > .relative {
.identity-picker-enter-active .identity-picker-card {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.identity-picker-leave-active > .relative {
.identity-picker-leave-active .identity-picker-card {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from { opacity: 0; }
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.identity-picker-enter-from .identity-picker-card { transform: translateY(10px) scale(0.98); opacity: 0; }
.identity-picker-leave-to { opacity: 0; }
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
.identity-picker-leave-to .identity-picker-card { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
+69 -128
View File
@@ -1,152 +1,93 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="deny"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
<button
@click="deny"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<Transition name="consent">
<div v-if="show" class="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6" @click.self="phase === 'review' && deny()">
<div ref="modalRef" class="nostr-consent-card glass-card w-full max-w-md overflow-y-auto p-5 sm:p-6" role="dialog" aria-modal="true" :aria-labelledby="`${dialogId}-title`" :aria-busy="phase === 'signing'">
<template v-if="phase === 'signing' || phase === 'success'">
<div class="flex min-h-[300px] flex-col items-center justify-center text-center">
<NostrIdentityOrb size="medium" :state="phase === 'success' ? 'success' : 'loading'" :aria-label="phase === 'success' ? 'Nostr request approved' : 'Signing with Nostr identity'" />
<h3 :id="`${dialogId}-title`" class="mt-4 text-xl font-semibold text-white">{{ phase === 'success' ? successTitle : progressTitle }}</h3>
<p class="mt-2 text-sm text-white/55">{{ phase === 'success' ? 'The app received the approved result.' : 'Your key stays on this node.' }}</p>
<p class="mt-4 text-[10px] uppercase tracking-[0.22em] text-white/25">NIP-07 · signed locally</p>
</div>
</template>
<template v-else>
<div class="mb-5 flex items-center gap-3">
<NostrIdentityOrb size="small" :state="phase === 'error' ? 'idle' : 'loading'" />
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-[0.2em] text-white/35">Nostr identity</p>
<h3 :id="`${dialogId}-title`" class="mt-1 text-xl font-semibold text-white">{{ phase === 'error' ? 'Request failed' : requestTitle }}</h3>
<p class="mt-1 truncate text-xs text-white/45">{{ appName }}</p>
</div>
<button type="button" class="rounded-lg p-2 text-white/55 transition-colors hover:bg-white/10 hover:text-white" aria-label="Close" @click="deny">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="space-y-3 mb-6">
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
<p class="text-white text-sm font-medium">{{ appName }}</p>
</div>
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
<p class="text-white text-sm font-medium">{{ method }}</p>
</div>
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
</div>
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
</div>
<div v-if="phase === 'error'" class="alert-error mb-5 text-sm">{{ error || 'The node could not complete this request.' }}</div>
<div v-else class="mb-5 space-y-2">
<div class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Request</p><p class="text-sm font-medium text-white">{{ methodLabel }}</p></div>
<div v-if="identityLabel" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Identity</p><p class="text-sm font-medium text-white">{{ identityLabel }}</p></div>
<div v-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="eventKind !== undefined" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Event kind</p><p class="text-sm font-medium text-white">{{ eventKind }} <span class="text-white/45">({{ eventKindLabel }})</span></p></div>
</div>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input
v-model="rememberChoice"
type="checkbox"
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
/>
<span class="text-white/70 text-sm">Remember for this app</span>
<label v-if="phase === 'review'" class="mb-5 flex cursor-pointer items-start gap-2">
<input v-model="rememberChoice" type="checkbox" class="mt-0.5 h-4 w-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50" />
<span class="text-sm leading-snug text-white/65">Remember for this app, identity, and request type</span>
</label>
<div class="flex gap-3">
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
Deny
</button>
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
Approve
</button>
<button type="button" class="glass-button flex-1 rounded-lg py-2.5 text-sm font-medium" @click="deny">{{ phase === 'error' ? 'Close' : 'Deny' }}</button>
<button v-if="phase === 'review'" type="button" class="glass-button flex-1 rounded-lg border-orange-400/30 py-2.5 text-sm font-medium text-orange-300" @click="approve">Approve</button>
</div>
</div>
</template>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
9734: 'Zap Request',
9735: 'Zap Receipt',
10002: 'Relay List',
30023: 'Long-form Content',
0: 'Metadata', 1: 'Short text note', 2: 'Recommend relay', 3: 'Contacts', 4: 'Encrypted DM',
5: 'Event deletion', 6: 'Repost', 7: 'Reaction', 1618: 'Git pull request', 1619: 'Git pull request update',
1621: 'Git issue', 9734: 'Zap request', 9735: 'Zap receipt', 10002: 'Relay list',
30023: 'Long-form content', 30617: 'Git repository announcement',
}
const props = defineProps<{
show: boolean
appName: string
method: string
eventKind?: number
content?: string
}>()
const emit = defineEmits<{
approve: [remember: boolean]
deny: []
}>()
const METHOD_LABELS: Record<string, string> = {
getPublicKey: 'Share public identity', signEvent: 'Sign Nostr event',
'nip04.encrypt': 'Encrypt a private message', 'nip04.decrypt': 'Decrypt a private message',
'nip44.encrypt': 'Encrypt protected content', 'nip44.decrypt': 'Decrypt protected content',
}
const props = withDefaults(defineProps<{
show: boolean; appName: string; method: string; identityLabel?: string; eventKind?: number; content?: string
phase?: 'review' | 'signing' | 'success' | 'error'; error?: string
}>(), { phase: 'review', error: '' })
const emit = defineEmits<{ approve: [remember: boolean]; deny: [] }>()
const modalRef = ref<HTMLElement | null>(null)
const rememberChoice = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
const contentPreview = computed(() => {
if (!props.content) return ''
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
const dialogId = `nostr-consent-${Math.random().toString(36).slice(2)}`
watch(() => props.show, show => { if (show) rememberChoice.value = false })
useModalKeyboard(modalRef, computed(() => props.show), () => {
if (props.phase === 'review' || props.phase === 'error') emit('deny')
})
const eventKindLabel = computed(() => {
if (props.eventKind === undefined) return ''
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
})
function approve() {
emit('approve', rememberChoice.value)
}
function deny() {
emit('deny')
}
const methodLabel = computed(() => METHOD_LABELS[props.method] ?? props.method)
const requestTitle = computed(() => props.method === 'getPublicKey' ? 'Share this identity?' : 'Approve this request?')
const progressTitle = computed(() => props.method === 'getPublicKey' ? 'Sharing identity…' : 'Signing locally…')
const successTitle = computed(() => props.method === 'getPublicKey' ? 'Identity shared' : 'Request signed')
const contentPreview = computed(() => !props.content ? '' : props.content.length > 200 ? `${props.content.slice(0, 200)}…` : props.content)
const eventKindLabel = computed(() => props.eventKind === undefined ? '' : EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown')
function approve() { emit('approve', rememberChoice.value) }
function deny() { emit('deny') }
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
.nostr-consent-card { max-height: min(90%, 680px); }
.consent-enter-active, .consent-leave-active { transition: opacity .22s ease; }
.consent-enter-active .nostr-consent-card, .consent-leave-active .nostr-consent-card { transition: transform .24s ease, opacity .2s ease; }
.consent-enter-from, .consent-leave-to { opacity: 0; }
.consent-enter-from .nostr-consent-card, .consent-leave-to .nostr-consent-card { opacity: 0; transform: translateY(10px) scale(.98); }
</style>
+267 -78
View File
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<BaseModal :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -21,22 +21,7 @@
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<!-- Paid: the invoice did its job — straight to the green check
(no broadcast step: Lightning settlement is final) -->
<div v-if="invoicePaid" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full flex items-center justify-center bg-green-500/15">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">{{ t('receiveBitcoin.paymentConfirmed') }}</p>
<p v-if="invoicePaid.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ invoicePaid.amountSats.toLocaleString() }} sats
</p>
</div>
<div v-else-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<CopyButton :value="invoiceResult" :label="t('common.copy')" />
@@ -45,50 +30,19 @@
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<!-- Payment detected: the QR did its job — show the outcome -->
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div
class="w-16 h-16 rounded-full flex items-center justify-center"
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'"
>
<!-- Check once confirmed, clock while in the mempool -->
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
</p>
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ paymentSeen.amountSats.toLocaleString() }} sats
</p>
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
{{ t('receiveBitcoin.paymentBroadcastHint') }}
</p>
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
<template v-else>
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</template>
</div>
<!-- Ark -->
@@ -106,20 +60,36 @@
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<!-- Shareable @minibits.cash Lightning address (LUD-16): any Lightning
wallet can pay this node by address, and the sats land as ecash.
Fetched on tab open; claimed payments are polled in while open. -->
<div v-if="lnAddress" class="mb-4 p-3 bg-white/5 rounded-lg text-center">
<p class="text-white/60 text-sm mb-2">{{ t('receiveBitcoin.lnAddressTitle') }}</p>
<canvas ref="lnAddressQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.lnAddressLabel') }}</p>
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
<CopyButton :value="lnAddress" :label="t('common.copy')" />
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
</p>
</div>
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
{{ t('receiveBitcoin.lnAddressLoading') }}
</div>
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
{{ t('receiveBitcoin.lnAddressUnavailable') }}
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<!-- Once the payment is seen there is nothing left to do here -->
<div v-if="paymentSeen" class="flex">
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
</div>
<div v-else class="flex gap-3">
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -132,6 +102,28 @@
</button>
</div>
</BaseModal>
<!-- Completion is deliberately its own modal, matching the Lightning
payment moment. It is not an inline status inside the receive form. -->
<BaseModal
:show="show && !!receiveSuccess"
title="Payment received"
max-width="max-w-2xl"
content-class="max-h-[90vh] overflow-y-auto"
@close="close"
>
<PaymentSuccessPane
v-if="receiveSuccess"
:amount="receiveSuccess.amount"
:verb="receiveSuccess.verb"
:method-label="receiveSuccess.methodLabel"
:rows="receiveSuccess.rows"
:note="receiveSuccess.note"
again-label="Receive another"
@again="receiveAnother"
@done="close"
/>
</BaseModal>
</template>
<script setup lang="ts">
@@ -140,6 +132,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -158,6 +151,8 @@ const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (!open) {
stopWatchingPayment()
stopWatchingInvoice()
stopLnClaimPoll()
return
}
paymentSeen.value = null
@@ -174,7 +169,14 @@ watch(() => props.show, (open) => {
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
receiveSuccess.value = null
stopLnClaimPoll()
lnAddress.value = ''
lnAddressLoading.value = false
lnAddressError.value = false
lnPendingClaims.value = 0
lastLnReceiptId.value = 0
lnWatchStartedAt.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -189,13 +191,146 @@ const invoiceResult = ref('')
const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const receiveSuccess = ref<{
amount: number
verb: string
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
// ── Minibits Lightning address (ecash receive) ──────────────────────────────
// The ecash tab doubles as "receive onto my @minibits.cash address": the node
// derives/registers it from its own ecash seed (wallet.ecash-lnaddress) and
// sweeps any Lightning payments that land there back into ecash while the tab is
// open (wallet.ecash-lnaddress-claim). A registration failure is never fatal —
// the paste-token path below always works.
const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
// A payment the backend fetched (and so already consumed at Minibits) but
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
// operator should see it rather than have it be a silent, unbounded wait.
const lnPendingClaims = ref(0)
const lastLnReceiptId = ref(0)
// A durable receipt is shared across browser contexts. Only surface one whose
// redemption happened after this receive watch began; reopening the modal must
// not replay the previous payment as though it were new.
const lnWatchStartedAt = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
let lnClaimInFlight = false
async function loadLnAddress() {
if (lnAddress.value || lnAddressLoading.value) return
lnAddressLoading.value = true
lnAddressError.value = false
try {
const res = await rpcClient.call<{ address?: string }>({
method: 'wallet.ecash-lnaddress',
timeout: 60_000,
})
lnAddress.value = res?.address || ''
if (lnAddress.value) {
await nextTick()
renderQr(lnAddress.value, lnAddressQrCanvas.value)
startLnClaimPoll()
} else {
lnAddressError.value = true
}
} catch {
lnAddressError.value = true
} finally {
lnAddressLoading.value = false
}
}
function stopLnClaimPoll() {
if (lnClaimTimer) {
clearInterval(lnClaimTimer)
lnClaimTimer = null
}
}
function startLnClaimPoll() {
stopLnClaimPoll()
// Do not make a newly opened receive screen wait a full interval before it
// can discover a payment (or a receipt claimed by another active client).
void pollLnClaims()
lnClaimTimer = setInterval(() => void pollLnClaims(), 3000)
}
async function pollLnClaims() {
if (lnClaimInFlight) return
if (!props.show || !lnAddress.value) {
stopLnClaimPoll()
return
}
lnClaimInFlight = true
try {
const res = await rpcClient.call<{
received_sats?: number
failed_count?: number
receipt_id?: number
receipt_sats?: number
receipt_at?: number
}>({
method: 'wallet.ecash-lnaddress-claim',
// Relay collection alone has a ten-second window and redemption may
// then contact the mint. Keep the browser request alive long enough for
// the backend's bounded work instead of timing out and queuing another.
timeout: 90_000,
})
// The user may have closed the modal while the relay fetch was in flight.
// Do not resurrect its status or emit a stale received event afterward.
if (!props.show || !lnAddress.value) return
const receiptId = res?.receipt_id || 0
const isNewReceipt = receiptId > 0 && receiptId !== lastLnReceiptId.value
const receiptAt = res?.receipt_at || 0
const receiptAgeSeconds = Math.floor(Date.now() / 1000) - receiptAt
const happenedDuringThisWatch = receiptAt >= Math.max(0, lnWatchStartedAt.value - 1)
const recentSharedReceipt = isNewReceipt
&& happenedDuringThisWatch
&& receiptAgeSeconds >= 0
&& receiptAgeSeconds <= 180
const receivedSats = res?.received_sats && res.received_sats > 0
? res.received_sats
: recentSharedReceipt ? (res?.receipt_sats || 0) : 0
if (receiptId > 0) lastLnReceiptId.value = receiptId
if (receivedSats > 0) {
receiveSuccess.value = {
amount: receivedSats,
verb: 'RECEIVED',
methodLabel: 'Lightning address to Cashu',
rows: [{ label: 'Lightning address', value: lnAddress.value }],
note: 'The payment was claimed into this node\'s Cashu balance.',
}
stopLnClaimPoll()
emit('received')
}
lnPendingClaims.value = res?.failed_count || 0
} catch {
// Transient poll failure (offline, mint busy) — keep polling.
} finally {
lnClaimInFlight = false
}
}
onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => {
if (m === 'ecash' && props.show) {
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
void loadLnAddress()
}
})
// ── On-chain payment detection ────────────────────────────────────────────
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
// transaction paying it is this receive — no baseline bookkeeping needed.
@@ -205,10 +340,6 @@ const error = ref('')
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
let watchTimer: ReturnType<typeof setInterval> | null = null
function midTxid(txid: string): string {
return txid.length > 24 ? `${txid.slice(0, 10)}…${txid.slice(-10)}` : txid
}
function stopWatchingPayment() {
if (watchTimer) {
clearInterval(watchTimer)
@@ -246,6 +377,15 @@ async function checkForPayment() {
amountSats: hit.amount,
confirmations: hit.num_confirmations,
}
receiveSuccess.value = {
amount: hit.amount,
verb: 'RECEIVED',
methodLabel: hit.num_confirmations > 0 ? 'Confirmed on-chain' : 'Received on-chain',
rows: [{ label: t('receiveBitcoin.transactionId'), value: hit.tx_hash }],
note: hit.num_confirmations > 0
? 'The transaction has its first confirmation.'
: t('receiveBitcoin.paymentBroadcastHint'),
}
if (firstSighting) emit('received')
if (hit.num_confirmations > 0) stopWatchingPayment()
} catch {
@@ -286,6 +426,12 @@ async function checkInvoice() {
})
if (!res.settled) return
invoicePaid.value = { amountSats: res.amt_paid_sat || invoiceAmount.value }
receiveSuccess.value = {
amount: res.amt_paid_sat || invoiceAmount.value,
verb: 'RECEIVED',
methodLabel: 'Received over Lightning',
rows: invoiceRHash.value ? [{ label: 'Payment hash', value: invoiceRHash.value }] : [],
}
stopWatchingInvoice()
emit('received')
} catch {
@@ -309,16 +455,46 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
function close() {
stopWatchingPayment()
stopWatchingInvoice()
stopLnClaimPoll()
paymentSeen.value = null
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
receiveSuccess.value = null
lnAddress.value = ''
lnPendingClaims.value = 0
lastLnReceiptId.value = 0
lnWatchStartedAt.value = 0
error.value = ''
emit('close')
}
/** Return to a clean form after a receipt without carrying a spent token or address forward. */
function receiveAnother() {
receiveSuccess.value = null
error.value = ''
processing.value = false
if (receiveMethod.value === 'lightning') {
stopWatchingInvoice()
invoicePaid.value = null
invoiceRHash.value = ''
invoiceResult.value = ''
invoiceAmount.value = 0
invoiceMemo.value = ''
} else if (receiveMethod.value === 'onchain') {
stopWatchingPayment()
paymentSeen.value = null
onchainAddress.value = ''
} else if (receiveMethod.value === 'ecash') {
ecashToken.value = ''
if (lnAddress.value) startLnClaimPoll()
} else {
arkAddress.value = ''
}
}
async function receive() {
processing.value = true
error.value = ''
@@ -356,14 +532,27 @@ async function receive() {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
const token = ecashToken.value.trim()
const res = await rpcClient.call<{ received_sats?: number; kind?: string; mint_url?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
params: { token },
})
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
const rows: SuccessRow[] = []
if (res.mint_url) rows.push({ label: 'Mint', value: res.mint_url })
rows.push({
label: kind === 'Fedimint' ? 'Notes redeemed' : 'Token redeemed',
value: token,
hint: 'Keep this if you ever need to show what was redeemed.',
truncate: true,
})
receiveSuccess.value = {
amount: res.received_sats || 0,
verb: 'RECEIVED',
methodLabel: kind,
rows,
}
ecashToken.value = ''
emit('received')
}
} catch (err: unknown) {
+24 -31
View File
@@ -188,17 +188,6 @@
</div>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<CopyButton :value="ecashToken" :label="t('common.copy')" size="sm" class="mt-2" />
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
@@ -218,12 +207,11 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
const { t } = useI18n()
@@ -285,9 +273,9 @@ const successInfo = ref<{
methodLabel: string
hash?: string
txid?: string
rows?: SuccessRow[]
note?: string
} | null>(null)
const ecashToken = ref('')
// The identifiers worth keeping from a completed send, in the shape the
// shared success pane takes. Which ones exist depends on the rail: Lightning
@@ -298,6 +286,7 @@ const successRows = computed<SuccessRow[]>(() => {
const rows: SuccessRow[] = []
if (info.hash) rows.push({ label: 'Payment hash', value: info.hash })
if (info.txid) rows.push({ label: 'Transaction ID', value: info.txid })
if (info.rows) rows.push(...info.rows)
return rows
})
@@ -346,7 +335,6 @@ watch(() => props.show, (shown) => {
dest.value = ''
error.value = ''
successInfo.value = null
ecashToken.value = ''
sendAll.value = false
onchainBalance.value = null
feePreset.value = 'standard'
@@ -569,7 +557,6 @@ async function review() {
function close() {
error.value = ''
ecashToken.value = ''
confirming.value = false
successInfo.value = null
emit('close')
@@ -585,24 +572,12 @@ function sendAnother() {
error.value = ''
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() {
if (processing.value) return
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
processing.value = true
error.value = ''
ecashToken.value = ''
const method = effectiveMethod.value
const paidAmount = confirmAmount.value
@@ -621,14 +596,32 @@ async function send() {
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
successInfo.value = {
amount: paidAmount,
methodLabel: 'Sent as Cashu',
rows: [{
label: 'Token to share',
value: res.token,
hint: 'The recipient needs this token to claim the sats. Keep it until they confirm receipt.',
truncate: true,
}],
}
} else if (method === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
ecashToken.value = res.token
successInfo.value = {
amount: paidAmount,
methodLabel: 'Sent as Fedimint ecash',
rows: [{
label: 'Notes to share',
value: res.token,
hint: 'The recipient needs these notes to claim the sats. Keep them until they confirm receipt.',
truncate: true,
}],
}
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
// Waits out slow multi-hop routing and only reports failure when LND
@@ -681,7 +674,7 @@ async function send() {
}
}
emit('sent')
// Success pane (or the token pane for ecash mints) takes over the modal.
// The shared success pane takes over the modal for every payment rail.
confirming.value = false
} catch (err: unknown) {
// Running node with nothing to pay with -> funding modal, not a raw string.
+35 -60
View File
@@ -234,35 +234,16 @@
</div>
<!-- ============ SUCCESS PANE ============ -->
<div v-else key="success" class="text-center py-2">
<div class="scan-success-badge mx-auto mb-5">
<ScreensaverRing size="badge" />
<div class="scan-success-core">
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successAmount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-2">{{ successVerb }}</div>
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
<span :class="railColor">{{ railLabel }}</span>
<span>balance now {{ balance.toLocaleString() }} sats</span>
</div>
<button
@click="close"
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Done
</button>
</div>
<PaymentSuccessPane
v-else
key="success"
:amount="successAmount"
:verb="successVerb"
:method-label="railLabel"
:rows="successRows"
:note="successNote"
@done="close"
/>
</Transition>
</div>
</div>
@@ -277,7 +258,7 @@ import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
@@ -706,6 +687,26 @@ const successAmount = ref(0)
const successVerb = ref('SENT')
const successDetail = ref('')
const successRef = ref('')
const successRows = computed<SuccessRow[]>(() => {
if (action.value === 'redeem-token' && dest.value) {
return [{
label: rail.value === 'fedimint' ? 'Notes redeemed' : 'Token redeemed',
value: dest.value,
hint: 'Keep this if you ever need to show what was redeemed.',
truncate: true,
}]
}
if (!successRef.value) return []
return [{
label: action.value === 'pay-invoice' ? 'Payment hash' : 'Transaction ID',
value: successRef.value,
}]
})
const successNote = computed(() => {
const parts = [successDetail.value]
if (balance.value !== null) parts.push(`${railLabel.value} balance now ${balance.value.toLocaleString()} sats.`)
return parts.filter(Boolean).join(' ')
})
async function confirmSend() {
if (processing.value) return
@@ -798,6 +799,10 @@ function resetAll() {
balance.value = null
processing.value = false
error.value = ''
successAmount.value = 0
successVerb.value = 'SENT'
successDetail.value = ''
successRef.value = ''
}
function close() {
@@ -868,34 +873,4 @@ onUnmounted(stopScanning)
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
/* Paid tick (FED-06) — same composition as SendBitcoinModal: the branded
ScreensaverRing badge with the emerald checkmark core centred over it. */
.scan-success-badge {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.scan-success-badge {
width: 192px;
height: 192px;
}
}
.scan-success-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6rem;
height: 6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
</style>
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import IdentitySuccessPane from '../IdentitySuccessPane.vue'
describe('IdentitySuccessPane', () => {
it('shows the saved identity, relay coverage, event id, and identity animation', () => {
const wrapper = mount(IdentitySuccessPane, {
props: {
identityName: 'Alice', eventId: 'abc123', accepted: 2, attempted: 3,
relayNote: 'One relay rejected the event.',
},
})
expect(wrapper.text()).toContain('IDENTITY UPDATED')
expect(wrapper.text()).toContain('Alice is updated on this node.')
expect(wrapper.text()).toContain('Published to 2/3 configured relays.')
expect(wrapper.text()).toContain('abc123')
expect(wrapper.text()).toContain('One relay rejected')
expect(wrapper.find('.nostr-orb-success').exists()).toBe(true)
})
it('reports a local-only save without claiming relay publication', () => {
const wrapper = mount(IdentitySuccessPane, {
props: { identityName: 'Alice', accepted: 0, attempted: 0 },
})
expect(wrapper.text()).toContain('Saved locally; no relay publication was attempted.')
})
})
@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NostrSignConsent from '../NostrSignConsent.vue'
const baseProps = {
show: true,
appName: 'GitWorkshop',
method: 'signEvent',
identityLabel: 'Personal',
eventKind: 1621,
}
describe('NostrSignConsent', () => {
it('renders as a contained absolute overlay, not a body teleport', () => {
const wrapper = mount(NostrSignConsent, { props: baseProps })
expect(wrapper.findComponent({ name: 'Teleport' }).exists()).toBe(false)
expect(wrapper.find('.absolute.inset-0').exists()).toBe(true)
expect(wrapper.text()).toContain('Approve this request?')
expect(wrapper.text()).toContain('Git issue')
})
it('uses the shared identity animation while the node signs', () => {
const wrapper = mount(NostrSignConsent, { props: { ...baseProps, phase: 'signing' } })
expect(wrapper.text()).toContain('Signing locally…')
expect(wrapper.find('.nostr-orb-loading').exists()).toBe(true)
expect(wrapper.findAll('.nostr-orb-segment')).toHaveLength(48)
expect(wrapper.text()).not.toContain('Approve')
})
it('shows a short in-app completion state', () => {
const wrapper = mount(NostrSignConsent, { props: { ...baseProps, phase: 'success' } })
expect(wrapper.text()).toContain('Request signed')
expect(wrapper.find('.nostr-orb-success').exists()).toBe(true)
})
})
@@ -69,8 +69,8 @@ describe('paid tick renders the branded ring (FED-06)', () => {
const rings = wrapper.findAllComponents(ScreensaverRing)
expect(rings).toHaveLength(1)
expect(rings[0]?.props('size')).toBe('badge')
expect(document.querySelector('.scan-success-core')).not.toBeNull()
expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
expect(document.querySelector('.burst-core')).not.toBeNull()
expect(document.querySelector('.burst-check')).not.toBeNull()
// The plain fixed-size circle the ring replaced is gone.
expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
@@ -0,0 +1,88 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import i18n from '@/i18n'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
payLightningInvoice: vi.fn(),
},
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
handleLightningFailure: vi.fn().mockReturnValue(false),
}),
}))
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
describe('payment completion receipts', () => {
it('replaces the Cashu send form and keeps the token copyable', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ token: 'cashuB-test-token' } as never)
const wrapper = mount(SendBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
const vm = wrapper.vm as unknown as {
sendMethod: string
amountEntry: number
send: () => Promise<void>
}
vm.sendMethod = 'ecash'
vm.amountEntry = 2100
await vm.send()
await flushPromises()
expect(document.body.textContent).toContain('2,100')
expect(document.body.textContent).toContain('SENT')
expect(document.body.textContent).toContain('cashuB-test-token')
expect(document.body.textContent).toContain('Token to share')
expect(document.body.querySelector('textarea')).toBeNull()
expect(document.body.querySelector('input[type="number"]')).toBeNull()
wrapper.unmount()
})
it('replaces the ecash receive form and preserves the redeemed token and mint', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({
received_sats: 42,
kind: 'cashu',
mint_url: 'https://mint.example',
} as never)
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
const vm = wrapper.vm as unknown as {
receiveMethod: string
ecashToken: string
receive: () => Promise<void>
}
vm.receiveMethod = 'ecash'
vm.ecashToken = 'cashuB-redeemed-token'
await vm.receive()
await flushPromises()
expect(document.body.textContent).toContain('42')
expect(document.body.textContent).toContain('RECEIVED')
expect(document.body.textContent).toContain('https://mint.example')
expect(document.body.textContent).toContain('cashuB-redeemed-token')
expect(document.body.querySelector('textarea')).toBeNull()
wrapper.unmount()
})
})
@@ -0,0 +1,70 @@
// Real vue-i18n instance (unlike ReceiveBitcoinModal.test.ts, which mocks
// `t` to a no-op and so cannot catch a bad message string). Operator report
// (2026-09-08): clicking the Ecash tab closed the whole Receive modal, in
// both the browser and the Android companion's WebView. Root cause: vue-i18n
// treats a bare `@` as the start of "linked message" syntax — `en.json`'s
// `receiveBitcoin.lnAddressLabel` ("Your @minibits.cash address:") isn't
// valid linked-message syntax, so *compiling* that message throws a
// SyntaxError the instant it's first rendered (i.e. the moment the address
// loads), and the uncaught render-function error blanks the whole teleported
// modal. Fixed by escaping it as `{'@'}` (the same pattern already used for
// `settings.domainNamePlaceholder`). This test uses the real compiler so a
// future bad interpolation string in this component fails fast in `npm test`
// instead of only in a live browser.
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
import i18n from '@/i18n'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
handleLightningFailure: vi.fn().mockReturnValue(false),
}),
}))
describe('ReceiveBitcoinModal — ecash tab with the real vue-i18n compiler', () => {
it('renders the Minibits address label without an uncaught render error', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
return { claimed_count: 0, received_sats: 0, failed_count: 0 } as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
let captured: unknown = null
wrapper.vm.$.appContext.app.config.errorHandler = (err) => { captured = err }
await flushPromises()
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
expect(ecashTab).toBeTruthy()
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
await flushPromises()
expect(captured).toBeNull()
expect(wrapper.emitted('close')).toBeFalsy()
const dialog = document.body.querySelector('[role="dialog"]')
expect(dialog).toBeTruthy()
expect(dialog?.textContent).toContain('minibits.cash')
expect(dialog?.textContent).toContain('someone@minibits.cash')
wrapper.unmount()
})
})
@@ -0,0 +1,184 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRoute: () => ({ fullPath: '/dashboard' }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
handleLightningFailure: vi.fn().mockReturnValue(false),
}),
}))
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
document.body.innerHTML = ''
})
// Guards an operator report (2026-09-08): clicking the Ecash tab appeared to
// close the whole Receive modal. Not reproduced here — the tab switch alone
// (success or failure of wallet.ecash-lnaddress) never emits `close` or
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
// path a future change could regress, so it's worth pinning down.
describe('ReceiveBitcoinModal — ecash tab click', () => {
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
await flushPromises()
const tabs = Array.from(document.body.querySelectorAll('button'))
const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash'))
expect(ecashTab).toBeTruthy()
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
wrapper.unmount()
})
it('does not close/emit when the ecash tab is clicked and the RPC fails', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('boom'))
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
await flushPromises()
const tabs = Array.from(document.body.querySelectorAll('button'))
const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash'))
expect(ecashTab).toBeTruthy()
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
wrapper.unmount()
})
it('never overlaps slow Lightning-address claim polls', async () => {
vi.useFakeTimers()
let finishClaim!: (value: unknown) => void
const slowClaim = new Promise((resolve) => { finishClaim = resolve })
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') return slowClaim as never
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
await vi.advanceTimersByTimeAsync(24_000)
const claimCalls = vi.mocked(rpcClient.call).mock.calls.filter(
([request]) => request.method === 'wallet.ecash-lnaddress-claim',
)
expect(claimCalls).toHaveLength(1)
finishClaim({ claimed_count: 0, received_sats: 0, failed_count: 0 })
await flushPromises()
wrapper.unmount()
vi.useRealTimers()
})
it('shows a recent receipt claimed by another active browser context', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') {
return {
received_sats: 0,
failed_count: 0,
receipt_id: 7,
receipt_sats: 1000,
receipt_at: Math.floor(Date.now() / 1000),
} as never
}
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(document.body.textContent).toContain('1,000')
expect(document.body.textContent).toContain('RECEIVED')
expect(document.body.querySelector('textarea')).toBeNull()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.querySelector('[role="dialog"] h3')?.textContent).toBe('Payment received')
wrapper.unmount()
})
it('does not replay an older durable receipt when the receive screen is reopened', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') {
return {
received_sats: 0,
failed_count: 0,
receipt_id: 7,
receipt_sats: 1000,
receipt_at: Math.floor(Date.now() / 1000) - 30,
} as never
}
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(document.body.querySelector('[role="dialog"] h3')?.textContent).not.toBe('Payment received')
expect(document.body.querySelector('textarea')).not.toBeNull()
wrapper.unmount()
})
})
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
companionRelease,
companionReleaseMarker,
loadCompanionRelease,
} from '../useCompanionRelease'
beforeEach(() => {
companionRelease.value = null
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('companion release metadata', () => {
it('uses the Android build number as the durable prompt marker', () => {
expect(companionReleaseMarker({ versionName: '0.5.32', versionCode: 52 })).toBe('build:52')
expect(companionReleaseMarker({ versionName: '0.5.32', versionCode: 0 })).toBe(
'version:0.5.32',
)
expect(companionReleaseMarker(null)).toBe('1')
})
it('loads the same no-cache metadata used by the download prompt', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ versionName: '0.5.32', versionCode: 52 }),
})
vi.stubGlobal('fetch', fetchMock)
await expect(loadCompanionRelease()).resolves.toEqual({
versionName: '0.5.32',
versionCode: 52,
})
expect(companionRelease.value).toEqual({ versionName: '0.5.32', versionCode: 52 })
expect(fetchMock).toHaveBeenCalledWith('/packages/archipelago-companion.json', {
cache: 'no-store',
})
})
})
@@ -0,0 +1,51 @@
import { ref } from "vue";
export interface CompanionRelease {
versionName: string;
versionCode: number;
}
// One shared value keeps the Discover banner and the automatic/manual
// Companion prompt on exactly the same release metadata.
export const companionRelease = ref<CompanionRelease | null>(null);
let releaseRequest: Promise<CompanionRelease | null> | null = null;
export function loadCompanionRelease(): Promise<CompanionRelease | null> {
if (companionRelease.value) return Promise.resolve(companionRelease.value);
if (releaseRequest) return releaseRequest;
releaseRequest = fetch("/packages/archipelago-companion.json", {
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) return null;
const metadata = await response.json();
if (
!metadata ||
typeof metadata.versionName !== "string" ||
!metadata.versionName
)
return null;
companionRelease.value = {
versionName: metadata.versionName,
versionCode: Number(metadata.versionCode) || 0,
};
return companionRelease.value;
})
.catch(() => null)
.finally(() => {
releaseRequest = null;
});
return releaseRequest;
}
export function companionReleaseMarker(
release: CompanionRelease | null,
): string {
if (release?.versionCode) return `build:${release.versionCode}`;
if (release?.versionName) return `version:${release.versionName}`;
return "1";
}
@@ -0,0 +1,48 @@
// Every message string must survive vue-i18n's message compiler. Found the
// hard way (2026-09-08): a bare `@` in a message is parsed as the start of
// "linked message" syntax (`@:key`), so a literal `@` (an email/handle-style
// placeholder, e.g. "user@example.com") throws a SyntaxError the first time
// it's *rendered*, not at build time — see [[vue-i18n-bare-at-sign-crash]]
// in project memory for the full incident (it blanked a whole modal in both
// the browser and the Android companion's WebView). A literal `@`, `{`, `}`
// or other message-syntax character must be escaped as e.g. `{'@'}`.
//
// This walks every string in every locale file and asks the real compiler
// to parse it — no rendering, no component needed, so it's fast and catches
// the whole class of bug regardless of which component ever ends up using
// the string.
import { describe, it, expect } from 'vitest'
import i18n from '@/i18n'
import en from '../en.json'
import es from '../es.json'
function collectStrings(obj: unknown, path: string, out: Array<[string, string]>) {
if (typeof obj === 'string') {
out.push([path, obj])
} else if (obj && typeof obj === 'object') {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
collectStrings(v, path ? `${path}.${k}` : k, out)
}
}
}
describe('locale messages compile', () => {
it.each([
['en', en],
['es', es],
])('every %s message string compiles under the real vue-i18n compiler', (_locale, messages) => {
const strings: Array<[string, string]> = []
collectStrings(messages, '', strings)
expect(strings.length).toBeGreaterThan(100)
const failures: string[] = []
for (const [path, msg] of strings) {
try {
i18n.global.t(path)
} catch (e) {
failures.push(`${path}: ${(e as Error).message.split('\n')[0]} (source: ${JSON.stringify(msg)})`)
}
}
expect(failures).toEqual([])
})
})
+8 -1
View File
@@ -315,7 +315,7 @@
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
"passwordNeedDigit": "Password must contain at least one digit",
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
"passwordNeedSpecial": "Password must contain at least one special character (!{'@'}#$%^&* etc.)",
"setupFailed": "Setup failed",
"verificationFailed": "Verification failed",
"disableFailed": "Failed to disable 2FA",
@@ -775,6 +775,13 @@
"paymentConfirmed": "Payment confirmed",
"transactionId": "Transaction ID",
"pasteEcashToken": "Paste ecash token",
"lnAddressTitle": "Or share your Minibits Lightning address",
"lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them. Minibits is a third-party beta service; keep balances small.",
"lnAddressLabel": "Your {'@'}minibits.cash address:",
"lnAddressLoading": "Setting up your Lightning address…",
"lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.",
"lnAddressReceived": "Received {amount} sats to your Lightning address!",
"lnAddressPendingRetry": "A payment arrived but couldn't be redeemed yet ({count}) — retrying automatically, keep this screen open.",
"processing": "Processing...",
"generateAddress": "Generate Address",
"createInvoice": "Create Invoice",
+8 -1
View File
@@ -315,7 +315,7 @@
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
"passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula",
"passwordNeedDigit": "La contrase\u00f1a debe contener al menos un d\u00edgito",
"passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!@#$%^&* etc.)",
"passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!{'@'}#$%^&* etc.)",
"setupFailed": "La configuraci\u00f3n fall\u00f3",
"verificationFailed": "La verificaci\u00f3n fall\u00f3",
"disableFailed": "Error al deshabilitar 2FA",
@@ -756,6 +756,13 @@
"paymentConfirmed": "Pago confirmado",
"transactionId": "ID de transacci\u00f3n",
"pasteEcashToken": "Pegar token Ecash",
"lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits",
"lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos. Minibits es un servicio beta de terceros; mant\u00e9n saldos peque\u00f1os.",
"lnAddressLabel": "Su direcci\u00f3n {'@'}minibits.cash:",
"lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026",
"lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.",
"lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!",
"lnAddressPendingRetry": "Lleg\u00f3 un pago pero a\u00fan no se pudo canjear ({count}) \u2014 reintentando autom\u00e1ticamente, mantenga esta pantalla abierta.",
"processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n",
"createInvoice": "Crear factura",
+21 -3
View File
@@ -83,6 +83,15 @@ const router = createRouter({
component: () => import('../views/KioskRecovery.vue'),
meta: { public: true },
},
{
// Dashboard-origin broker embedded by top-level node apps. It reuses the
// exact contained identity/consent components used by app sessions; the
// route remains authenticated and accepts only same-node parent origins.
path: '/nostr-signer',
name: 'nostr-signer',
component: () => import('../views/NostrTabSigner.vue'),
meta: { signerBroker: true },
},
{
// The kiosk display no longer has its own launcher screen. It runs the
// normal app (onboarding → login → dashboard) like any other client.
@@ -268,10 +277,14 @@ const router = createRouter({
// Session check with timeout - avoids endless spinner on mobile/slow networks
const SESSION_CHECK_TIMEOUT_MS = 8000
async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): Promise<boolean> {
async function checkSessionWithTimeout(
store: ReturnType<typeof useAppStore>,
allowCookieWithoutLocalMarker = false,
bootstrapDashboard = true,
): Promise<boolean> {
try {
return await Promise.race([
store.checkSession(),
store.checkSession({ allowCookieWithoutLocalMarker, bootstrapDashboard }),
new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS)
),
@@ -339,7 +352,12 @@ router.beforeEach(async (to, _from, next) => {
// Not authenticated at all (with timeout to avoid endless spinner on mobile)
if (!store.isAuthenticated) {
const hasSession = await checkSessionWithTimeout(store)
// A direct app tab can authenticate through the app gate before its
// dashboard-origin signer iframe has ever loaded. Ports share the node's
// host-only cookie, but not localStorage, so the broker must probe that
// cookie instead of mistaking the missing dashboard marker for logout.
const signerBroker = Boolean(to.meta.signerBroker)
const hasSession = await checkSessionWithTimeout(store, signerBroker, !signerBroker)
if (hasSession) {
next()
return
+32
View File
@@ -137,6 +137,38 @@ describe('useAppStore', () => {
const valid = await store.checkSession()
expect(valid).toBe(false)
expect(mockedRpc.call).not.toHaveBeenCalled()
})
it('checkSession can validate an app-gate cookie without a dashboard localStorage marker', async () => {
mockedRpc.call.mockResolvedValue('ping')
const store = useAppStore()
const valid = await store.checkSession({
allowCookieWithoutLocalMarker: true,
bootstrapDashboard: false,
})
expect(valid).toBe(true)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'system.get-hostname',
})
expect(mockedRpc.call).toHaveBeenCalledOnce()
expect(mockedWs.connect).not.toHaveBeenCalled()
expect(store.data).toBeNull()
expect(store.isAuthenticated).toBe(true)
expect(localStorage.getItem('neode-auth')).toBe('true')
})
it('checkSession rejects a missing app-gate cookie when explicitly probed', async () => {
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
const store = useAppStore()
const valid = await store.checkSession({ allowCookieWithoutLocalMarker: true })
expect(valid).toBe(false)
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('neode-auth')).toBeNull()
})
it('checkSession returns false and clears state on expired session', async () => {
@@ -14,9 +14,10 @@ const SIGNED = {
}
// vi.hoisted runs before vi.mock hoisting
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
const { mockPush, mockWindowOpen, mockRpcCall } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockRpcCall: vi.fn(),
}))
// Mock vue-router
@@ -26,6 +27,9 @@ vi.mock('vue-router', () => ({
vi.mock('@/router', () => ({
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: mockRpcCall },
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -35,6 +39,7 @@ describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRpcCall.mockResolvedValue({ credentials: [] })
__setSignedCatalogForTests(SIGNED as never)
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
@@ -66,9 +71,13 @@ describe('useAppLauncherStore', () => {
delete (window as any).ArchipelagoNative
})
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
it('shows credentials before handing an app to the native WebView', async () => {
const store = useAppLauncherStore()
store.openSession('filebrowser')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(openInApp).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
@@ -82,6 +91,16 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBeNull()
})
it('opens GitWorkshop in the companion native WebView, never a dashboard iframe', () => {
const store = useAppLauncherStore()
store.openSession('archipelago-source')
expect(openInApp).toHaveBeenCalledWith(
'http://192.0.2.10/app/archipelago-source/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy',
)
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
})
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown app' })
@@ -90,11 +109,13 @@ describe('useAppLauncherStore', () => {
})
})
it('routes known port apps to full-page session', () => {
it('routes known port apps to full-page session after the credential gate', async () => {
const store = useAppLauncherStore()
// Port 8083 maps to /app/filebrowser/ — should route to session
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Default panel mode: sets panelAppId, doesn't open overlay
expect(store.isOpen).toBe(false)
@@ -102,6 +123,29 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('gates a Home-style Portainer launch until its first-run token is shown', async () => {
mockRpcCall.mockResolvedValueOnce({
title: 'Portainer first-run token',
description: 'Use this token to create the administrator account.',
credentials: [{ label: 'Token', value: 'test-token', sensitive: true }],
})
const store = useAppLauncherStore()
store.openSession('portainer')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(store.credentialPrompt.credentials[0]?.value).toBe('test-token')
expect(mockWindowOpen).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(':9000'),
'_blank',
'noopener,noreferrer',
)
})
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
@@ -378,7 +422,7 @@ describe('useAppLauncherStore', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('routes HTTPS same-host apps via session view', () => {
it('routes HTTPS same-host apps via session view after the credential gate', async () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
@@ -387,6 +431,8 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Known port — routes to session (panel mode by default)
expect(store.isOpen).toBe(false)
+200 -36
View File
@@ -4,11 +4,19 @@ import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { portIsGateFronted } from '@/views/discover/curatedApps'
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig'
import { useToast } from '@/composables/useToast'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from '@/views/appSession/nostrConsent'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -79,6 +87,22 @@ const NEW_TAB_APP_IDS = new Set([
'netbird',
])
/** Apps whose launch may require a platform-owned credential handoff. Keep
* this list deliberately narrow so ordinary new-tab launches retain their
* original synchronous user gesture. Portainer is dynamic (first-run only);
* File Browser and PhotoPrism have stable fallback credentials. */
export const CREDENTIAL_INTERSTITIAL_APPS = new Set([
'filebrowser',
'photoprism',
'portainer',
])
interface LaunchOptions {
path?: string
/** The shared interstitial already ran and the user pressed Continue. */
skipCredentialPrompt?: boolean
}
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -148,6 +172,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8337': 'archipelago-source',
'8888': 'searxng',
'9000': 'portainer',
'8087': 'netbird',
@@ -163,30 +188,12 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
if (!stored) return new Set()
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
} catch {
return new Set()
}
}
function saveApprovedOrigin(origin: string) {
const origins = getApprovedOrigins()
origins.add(origin)
try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ }
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
identityLabel?: string
resolve: (remember: boolean) => void
reject: () => void
}
@@ -208,6 +215,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const title = ref('')
const consentRequest = ref<NostrConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
const credentialPrompt = ref({
show: false,
loading: false,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
let pendingCredentialLaunch: { appId: string; path?: string } | null = null
let credentialGeneration = 0
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
let previousActiveElement: HTMLElement | null = null
/** Active app in the store-driven session (no route change) */
@@ -215,15 +238,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
function openSession(appId: string, opts: { path?: string } = {}) {
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Companion app: EVERY app opens in the native in-app WebView — never an
// iframe. The WebView is more performant on the phone and carries the
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
// keep the iframe session below.
if (!IS_DEMO && isCompanionApp()) {
// Companion app: ordinary apps open in the native in-app WebView for the
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -264,6 +287,102 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
panelAppId.value = appId
}
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
* Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) {
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path)
return
}
openSessionNow(appId, opts)
}
async function prepareCredentialLaunch(appId: string, path?: string) {
const generation = ++credentialGeneration
const appName = useAppStore().data?.['package-data']?.[appId]?.manifest?.title || appId
pendingCredentialLaunch = { appId, path }
credentialPrompt.value = {
show: true,
loading: true,
appId,
title: `Checking ${appName}`,
description: 'Checking whether this app needs a first-run token or login details…',
credentials: [],
copied: '',
}
let result: AppCredentialsResponse | null
try {
// Portainer's token is lifecycle-dependent, so this must be live on
// every launch. Caching a pre-initialisation null (or an already-used
// token) recreates the skipped/stale interstitial bug.
result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: appId },
timeout: 5000,
})
} catch {
result = null
}
if (generation !== credentialGeneration) return
const resolved = resolveAppCredentials(appId, result)
if (!resolved) {
credentialPrompt.value.show = false
pendingCredentialLaunch = null
openSessionNow(appId, { path, skipCredentialPrompt: true })
return
}
credentialPrompt.value = {
show: true,
loading: false,
appId,
title: resolved.title || `${appName} credentials`,
description: resolved.description || 'Use these credentials when the app asks you to sign in.',
credentials: resolved.credentials,
copied: '',
}
}
function cancelCredentialLaunch() {
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
}
function continueCredentialLaunch() {
const pending = pendingCredentialLaunch
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
if (pending) openSessionNow(pending.appId, { path: pending.path, skipCredentialPrompt: true })
}
async function copyCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialPrompt.value.copied = label
}
function closePanel() {
panelAppId.value = null
panelPath.value = null
@@ -287,7 +406,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const sameHost = u.hostname === window.location.hostname
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
const httpsPage = window.location.protocol === 'https:'
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
const gateFronted = !!resolvedId && appPortIsGateFronted(resolvedId, u.port)
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
// Pure prefix swap — never re-serialize the URL (URL.href would add
// a trailing slash and change the string the caller handed over).
@@ -366,6 +485,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
if (showConsent.value) denyConsent()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -384,22 +504,50 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function approveConsent(remember: boolean) {
if (consentRequest.value) {
consentRequest.value.resolve(remember)
consentRequest.value = null
}
showConsent.value = false
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
if (consentRequest.value) {
consentRequest.value.reject()
consentRequest.value = null
}
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
async function finishConsentSuccess() {
const generation = approvedGeneration
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
if (generation !== consentGeneration || !showConsent.value) return
consentPhase.value = 'success'
await new Promise(resolve => setTimeout(resolve, 325))
if (generation !== consentGeneration) return
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
}
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string, identityLabel?: string): Promise<boolean> {
return new Promise((resolve, reject) => {
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
consentGeneration += 1
consentRequest.value = {
appName, method, eventKind, content, identityLabel,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
@@ -416,6 +564,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
if (!senderMatchesApp(url.value, event.origin)) return
const origin = event.origin
let prompted = false
const activeAppId = resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app'
// Check if app has a per-app identity stored (from identity picker)
const IDENTITY_KEY = 'archipelago_app_identity_'
@@ -440,12 +590,18 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
if (CONSENT_METHODS.has(method) && !getApprovedOrigins().has(origin)) {
const scopedKey = consentKey(origin, activeAppId, appIdentityId || 'node-default', method)
const alreadyApproved = hasRememberedConsent(scopedKey)
if (CONSENT_METHODS.has(method) && !alreadyApproved) {
prompted = true
const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined
const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined
try {
const remember = await requestConsent(title.value || 'App', method, eventKind, content)
if (remember) saveApprovedOrigin(origin)
const remember = await requestConsent(
title.value || 'App', method, eventKind, content,
appIdentityId || 'Node default identity',
)
if (remember) rememberConsent(scopedKey)
} catch {
source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*')
return
@@ -508,9 +664,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
if (prompted) void finishConsentSuccess()
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
if (prompted && showConsent.value) finishConsentError(err)
}
}
@@ -533,8 +691,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
closePanel,
panelAppId,
panelPath,
credentialPrompt,
cancelCredentialLaunch,
continueCredentialLaunch,
copyCredential,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
+26 -8
View File
@@ -92,22 +92,40 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function checkSession(): Promise<boolean> {
if (!localStorage.getItem('neode-auth')) {
async function checkSession(options: {
allowCookieWithoutLocalMarker?: boolean
bootstrapDashboard?: boolean
} = {}): Promise<boolean> {
// `neode-auth` is only a client-side hint; the HttpOnly session cookie is
// the authority. Most dashboard navigations deliberately require the hint
// so logging out does not immediately resurrect a still-expiring cookie.
// The contained tab signer is the exception: an app-gate login happens on
// the app's port and sets the shared host cookie, but cannot set dashboard-
// origin localStorage. Let that route validate the real cookie explicitly.
if (!options.allowCookieWithoutLocalMarker && !localStorage.getItem('neode-auth')) {
return false
}
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
// Unlike public `server.echo`, this implemented read-only method requires
// a valid session while remaining CSRF-exempt. That makes checkSession a
// real authentication check, including for the app-gate cookie bootstrap.
await rpcClient.call({ method: 'system.get-hostname' })
isAuthenticated.value = true
sessionValidated = true
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
const sync = useSyncStore()
await sync.initializeData()
// The hidden signer broker only needs proof of the session cookie. Do
// not make its first consent prompt wait for a full dashboard snapshot
// and WebSocket connection; a normal dashboard check keeps this default.
if (options.bootstrapDashboard !== false) {
const sync = useSyncStore()
await sync.initializeData()
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
}
return true
} catch (err) {
+18
View File
@@ -3074,9 +3074,27 @@ html.modal-scroll-locked .dashboard-scroll-panel {
background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.5) 40%, rgba(0,0,0,0.1) 100%);
border-radius: 16px;
}
.source-banner {
border-color: rgba(147, 51, 234, 0.35);
}
.source-banner-img {
object-position: center center;
opacity: 0.72;
}
.source-banner:hover .source-banner-img {
opacity: 0.86;
}
.source-banner-overlay {
justify-content: center;
background: linear-gradient(90deg, rgba(3,5,9,0.97) 0%, rgba(3,5,9,0.86) 42%, rgba(3,5,9,0.2) 76%, rgba(3,5,9,0.08) 100%);
}
@media (max-width: 768px) {
.featured-banner { min-height: 240px; }
.featured-banner-overlay { padding: 1.5rem; min-height: 240px; }
.source-banner-img { object-position: 68% center; }
.source-banner-overlay {
background: linear-gradient(to right, rgba(3,5,9,0.96) 0%, rgba(3,5,9,0.78) 72%, rgba(3,5,9,0.42) 100%);
}
}
.discover-stat-pill {
@@ -1,5 +1,9 @@
import { describe, it, expect, afterEach } from 'vitest'
import { isCompanionApp } from '../openExternal'
import { describe, it, expect, afterEach, vi } from 'vitest'
import {
installCertificateInCompanion,
isCompanionApp,
openInAppOrNewTab,
} from '../openExternal'
// isCompanionApp() is the single companion-detection source used to skip the
// demo intro (App.vue + RootRedirect.vue) and by appLauncher — it must be true
@@ -26,4 +30,21 @@ describe('isCompanionApp', () => {
w.ArchipelagoNative = { openExternal: () => {} }
expect(isCompanionApp()).toBe(false)
})
it('absolutizes same-origin paths before handing them to the native WebView', () => {
const openInApp = vi.fn()
w.ArchipelagoNative = { openInApp }
openInAppOrNewTab('/app/archipelago-source/')
expect(openInApp).toHaveBeenCalledWith(`${window.location.origin}/app/archipelago-source/`)
})
it('hands a certificate to the native installer when available', () => {
const installNodeCertificate = vi.fn()
w.ArchipelagoNative = { openInApp: () => {}, installNodeCertificate }
expect(installCertificateInCompanion()).toBe(true)
expect(installNodeCertificate).toHaveBeenCalledOnce()
})
})
+23 -2
View File
@@ -15,6 +15,8 @@ interface ArchipelagoNativeBridge {
/** Richer launch (companion ≥0.5.26): catalog icon + display name drive the
* native branded loader instead of the site favicon. */
openInAppEx?: (url: string, iconUrl: string, name: string) => void
/** Install only the connected node's CA through Android's system prompt. */
installNodeCertificate?: () => void
}
/** Optional app identity for the native loading screen. */
@@ -58,15 +60,34 @@ export function openExternalUrl(url: string): void {
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void {
if (!url) return
const native = nativeBridge()
// Native WebView.loadUrl requires a complete URL. Browser APIs accept
// relative paths, so this only surfaced when a same-origin app was launched
// by the companion bridge.
let nativeUrl = url
if (!/^[a-z][a-z\d+.-]*:/i.test(url)) {
try {
nativeUrl = new URL(url, window.location.origin).href
} catch { /* keep as-is */ }
}
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
// Absolutize the icon path so the native shell can fetch it directly.
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
native.openInAppEx(url, icon, meta.name ?? '')
native.openInAppEx(nativeUrl, icon, meta.name ?? '')
return
}
if (native && typeof native.openInApp === 'function') {
native.openInApp(url)
native.openInApp(nativeUrl)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
/** Use Android's credential installer when the dashboard runs in the
* companion. Returns false in an ordinary browser so the caller can preserve
* the normal file-download behavior. */
export function installCertificateInCompanion(): boolean {
const native = nativeBridge()
if (!native || typeof native.installNodeCertificate !== 'function') return false
native.installNodeCertificate()
return true
}

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