feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 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()
},
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## Unreleased
- **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. This remains development-node-only pending owner UAT, a clear upstream redistribution license, dependency review, and canonical Archipelago NIP-34/GRASP testing.
- **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.
+31
View File
@@ -34,6 +34,37 @@ 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",
"launchLabel": "Open",
"installLabel": "Install",
"detailsLabel": "Learn more →"
}]
}
}
```
Only IDs present in `apps` render. 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
+36
View File
@@ -9,6 +9,29 @@
"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",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "adguardhome",
@@ -247,6 +270,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
+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
+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
+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\" =>"));
}
}
+180 -194
View File
@@ -74,56 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
pub(super) 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",
])
.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;
}
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 {
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();
let conf = conf.replace(
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\
@@ -131,53 +90,162 @@ pub(super) async fn patch_indeedhub_nostr_provider() {
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
}
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\"></script></head>';",
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;",
)
} else {
conf
};
}
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"])
pub(crate) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 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
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
.unwrap_or(false);
if !running {
return;
}
// `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 _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
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;
};
let _ = tokio::process::Command::new("podman")
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([
"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",
"unshare",
"install",
"-m",
"644",
provider_src,
&provider_dest,
])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false);
let copy_out = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await;
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
.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(["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)
);
}
@@ -1620,124 +1688,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
@@ -2800,11 +2754,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 [
@@ -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;
+3
View File
@@ -442,6 +442,9 @@ impl RpcHandler {
"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,
}))
}
+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]
+2 -2
View File
@@ -7,6 +7,6 @@
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,
18081, 18083, 23000, 32838, 50002,
8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380,
11434, 18081, 18083, 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.
+98 -7
View File
@@ -64,6 +64,7 @@ use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info, warn};
@@ -73,6 +74,12 @@ use tracing::{debug, info, warn};
// lasts longer than the UI's poll interval; serialise them so two polls cannot
// consume the same claim and race each other's state file writes.
static MINIBITS_STATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
// A Companion WebView, its external browser tab, and a desktop dashboard can
// all watch the same address. Once one caller has completed the expensive
// relay fetch, callers already queued behind it should return the durable
// receipt immediately instead of each opening another relay subscription.
static LAST_MINIBITS_POLL_COMPLETED_AT: AtomicU64 = AtomicU64::new(0);
const MINIBITS_POLL_COALESCE_SECS: u64 = 2;
/// Minibits profile/LNURL API. Confirmed live: `/v3/auth/challenge`,
/// `/v3/profile`, `/v3/claim` (the older `/v2` host no longer serves profiles).
@@ -166,6 +173,16 @@ pub struct MinibitsState {
/// We query the boundary second inclusively and deduplicate by event id.
#[serde(default)]
pub seen_dm_ids: Vec<String>,
/// Monotonic id for the latest successfully redeemed claim batch. Claim
/// polling may come from several browser/Companion contexts; keeping the
/// latest receipt here lets every caller observe the result instead of
/// only whichever request happened to acquire the claim lock first.
#[serde(default)]
pub last_receipt_id: u64,
#[serde(default)]
pub last_receipt_sats: u64,
#[serde(default)]
pub last_receipt_at: u64,
}
/// A retryable encrypted token and the server key that encrypted it. The
@@ -611,6 +628,9 @@ async fn register_new_state(
pending_claims: Vec::new(),
last_dm_seen_at: 0,
seen_dm_ids: Vec::new(),
last_receipt_id: 0,
last_receipt_sats: 0,
last_receipt_at: 0,
})
}
@@ -625,14 +645,38 @@ pub struct ClaimOutcome {
/// dropped. Non-zero here means real, unswept value the operator should
/// know about.
pub failed_count: usize,
/// Most recent successful receipt, including one redeemed by another
/// concurrent UI poll. Zero means this wallet has no recorded receipt.
pub receipt_id: u64,
pub receipt_sats: u64,
pub receipt_at: u64,
}
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
claimed_count: 0,
received_sats: 0,
failed_count: 0,
receipt_id: 0,
receipt_sats: 0,
receipt_at: 0,
};
fn outcome_with_latest_receipt(
state: &MinibitsState,
claimed_count: usize,
received_sats: u64,
failed_count: usize,
) -> ClaimOutcome {
ClaimOutcome {
claimed_count,
received_sats,
failed_count,
receipt_id: state.last_receipt_id,
receipt_sats: state.last_receipt_sats,
receipt_at: state.last_receipt_at,
}
}
/// Make sure the Minibits mint is on the accepted-mints allow-list.
///
/// `ecash::receive_token` checks the raw accepted-mints file directly (not
@@ -667,7 +711,7 @@ async fn fetch_relay_dms(
client.connect().await;
// Give relays a moment to finish the WebSocket handshake before the
// fetch's own timeout starts consuming that time.
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
// Nostr timestamps have one-second resolution. Query the boundary second
// inclusively: a later-published payment may legitimately share that
@@ -680,7 +724,7 @@ async fn fetch_relay_dms(
.limit(200);
let result = match client
.fetch_events(filter, std::time::Duration::from_secs(10))
.fetch_events(filter, std::time::Duration::from_secs(5))
.await
{
Ok(events) => {
@@ -782,6 +826,21 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
// means a payment could not have arrived, so claiming is a no-op.
None => return Ok(NO_CLAIMS),
};
// The global lock serialises claim redemption, but without this fast path
// every browser waiting on that lock performed its own five-to-ten-second
// relay fetch in turn. Reuse the just-completed durable result for the
// short coalescing window; a later UI poll performs the next real fetch.
let now = chrono::Utc::now().timestamp().max(0) as u64;
let last_completed = LAST_MINIBITS_POLL_COMPLETED_AT.load(Ordering::Acquire);
if last_completed > 0 && now.saturating_sub(last_completed) <= MINIBITS_POLL_COALESCE_SECS {
return Ok(outcome_with_latest_receipt(
&state,
0,
0,
state.pending_claims.len(),
));
}
ensure_token(&client, &mut state, &identity.keys).await?;
// Refresh the service key that authors and encrypts claim DMs. Keeping the
@@ -864,7 +923,11 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
save_state(data_dir, &state).await?;
if state.pending_claims.is_empty() {
return Ok(NO_CLAIMS);
LAST_MINIBITS_POLL_COMPLETED_AT.store(
chrono::Utc::now().timestamp().max(0) as u64,
Ordering::Release,
);
return Ok(outcome_with_latest_receipt(&state, 0, 0, 0));
}
let to_process = std::mem::take(&mut state.pending_claims);
@@ -910,13 +973,23 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
let failed_count = still_pending.len();
state.pending_claims = still_pending;
if sats > 0 {
state.last_receipt_id = state.last_receipt_id.saturating_add(1).max(1);
state.last_receipt_sats = sats;
state.last_receipt_at = chrono::Utc::now().timestamp().max(0) as u64;
}
save_state(data_dir, &state).await?;
LAST_MINIBITS_POLL_COMPLETED_AT.store(
chrono::Utc::now().timestamp().max(0) as u64,
Ordering::Release,
);
Ok(ClaimOutcome {
claimed_count: redeemed,
received_sats: sats,
Ok(outcome_with_latest_receipt(
&state,
redeemed,
sats,
failed_count,
})
))
}
#[cfg(test)]
@@ -1117,6 +1190,24 @@ mod tests {
state.pending_claims[0].sender_pubkey("cached-server-key"),
"cached-server-key"
);
assert_eq!(state.last_receipt_id, 0);
assert_eq!(state.last_receipt_sats, 0);
assert_eq!(state.last_receipt_at, 0);
}
#[test]
fn latest_receipt_survives_a_zero_claim_poll() {
let state = MinibitsState {
last_receipt_id: 9,
last_receipt_sats: 1_000,
last_receipt_at: 1_789_000_000,
..Default::default()
};
let outcome = outcome_with_latest_receipt(&state, 0, 0, 0);
assert_eq!(outcome.received_sats, 0);
assert_eq!(outcome.receipt_id, 9);
assert_eq!(outcome.receipt_sats, 1_000);
assert_eq!(outcome.receipt_at, 1_789_000_000);
}
#[tokio::test]
+36
View File
@@ -0,0 +1,36 @@
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
RUN npm ci
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;"]
+23
View File
@@ -0,0 +1,23 @@
# 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 small build 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 pinned revision and current upstream `main` do not contain a license file,
the package metadata declares no license, and GitHub reports no detected
license. This development image is for local evaluation only. Do not publish
it to the production app registry until upstream adds an explicit license (the
preferred auditable resolution) or its maintainer provides a written grant
covering compilation, modification, and redistribution.
@@ -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>
+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;
}
}
+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`.
+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
+224 -198
View File
@@ -1,252 +1,278 @@
# 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-08
## Goals
**Status:** GitWorkshop integration is deployed and engineering-tested on the
development node, ready for owner UAT. Canonical repository publication and
release work remain separate gates. No app-registry, OTA, ISO, or production
artifact may be published until the owner accepts the node deployment.
- 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 Gate
- `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. Local engineering and owner evaluation may continue, but the
compiled image must not be published to the production app registry until its
redistribution terms are unambiguous.
### Contributor Onboarding
Preferred resolution: 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 — pending owner action
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 validates install, launch, navigation, repository discovery, identity
selection, consent prompts, source browsing, and available contribution flows.
Engineering fixes UAT findings on this node and repeats the gate. Owner UAT is
not inferred from automated tests.
## 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 — explicitly blocked pending prior 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, owner UAT acceptance, canonical launch tests,
license confirmation, and dependency review 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?
- Will upstream add an explicit GitWorkshop license file, or provide another
written redistribution grant suitable for registry publication?
@@ -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"
@@ -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')
})
+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

+36
View File
@@ -9,6 +9,29 @@
"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",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "adguardhome",
@@ -247,6 +270,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",
+347 -107
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) {
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);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
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();
}
}).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);
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 });
}
})();
+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
}
}
)
@@ -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>
+34 -135
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')"
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')"
>
<!-- Backdrop — frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<!-- 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 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>
<!-- 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>
<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,6 +37,14 @@
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<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="identities.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>
@@ -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>
</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
@@ -156,6 +146,7 @@ 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)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
@@ -174,14 +165,18 @@ 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)
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
}
@@ -207,116 +202,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>
+67 -126
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 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>
<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>
<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>
</template>
</div>
</div>
</Transition>
</Teleport>
</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>
+141 -77
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,36 +30,6 @@
<!-- 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>
<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>
@@ -88,7 +43,6 @@
<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 -->
@@ -116,9 +70,6 @@
<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="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
</p>
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
</p>
@@ -134,16 +85,11 @@
<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">
@@ -156,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">
@@ -164,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'
@@ -182,6 +151,8 @@ const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (!open) {
stopWatchingPayment()
stopWatchingInvoice()
stopLnClaimPoll()
return
}
paymentSeen.value = null
@@ -198,13 +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
lnClaimedSats.value = 0
lnPendingClaims.value = 0
lastLnReceiptId.value = 0
lnWatchStartedAt.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -219,7 +191,13 @@ 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)
@@ -236,11 +214,15 @@ const error = ref('')
const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
const lnClaimedSats = ref(0)
// 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
@@ -277,7 +259,10 @@ function stopLnClaimPoll() {
function startLnClaimPoll() {
stopLnClaimPoll()
lnClaimTimer = setInterval(() => void pollLnClaims(), 8000)
// 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() {
@@ -288,7 +273,13 @@ async function pollLnClaims() {
}
lnClaimInFlight = true
try {
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
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
@@ -298,8 +289,28 @@ async function pollLnClaims() {
// 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
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
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
@@ -314,7 +325,10 @@ onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => {
if (m === 'ecash' && props.show) void loadLnAddress()
if (m === 'ecash' && props.show) {
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
void loadLnAddress()
}
})
// ── On-chain payment detection ────────────────────────────────────────────
@@ -326,10 +340,6 @@ watch(receiveMethod, (m) => {
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)
@@ -367,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 {
@@ -407,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 {
@@ -430,20 +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 = ''
lnClaimedSats.value = 0
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 = ''
@@ -481,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()
})
})
@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -27,6 +27,12 @@ vi.mock('@/composables/useLightningRequired', () => ({
}),
}))
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
@@ -108,4 +114,71 @@ describe('ReceiveBitcoinModal — ecash tab click', () => {
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()
})
})
+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/',
)
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)
+190 -35
View File
@@ -4,11 +4,18 @@ 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 { 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 +86,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 +171,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 +187,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 +214,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 +237,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 +286,94 @@ 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 = {}) {
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 +397,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 +476,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
if (showConsent.value) denyConsent()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -384,22 +495,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 +555,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 +581,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 +655,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 +682,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
closePanel,
panelAppId,
panelPath,
credentialPrompt,
cancelCredentialLaunch,
continueCredentialLaunch,
copyCredential,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
+21 -3
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 */ }
// 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)
})
}
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
}
+14 -3
View File
@@ -262,12 +262,23 @@ const canLaunch = computed(() => {
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => [
const features = computed(() => {
if (packageKey.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
])
'Secure by default',
]
})
function goBack() {
if (route.query.from === 'discover') {
+31 -5
View File
@@ -85,15 +85,29 @@
</svg>
</button>
</div>
</div>
<!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. -->
<NostrSignConsent
:show="nostrBridge.showConsent.value"
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
:method="nostrBridge.consentRequest.value?.method ?? ''"
:identity-label="nostrBridge.consentRequest.value?.identityLabel"
:event-kind="nostrBridge.consentRequest.value?.eventKind"
:content="nostrBridge.consentRequest.value?.content"
:phase="nostrBridge.consentPhase.value"
:error="nostrBridge.consentError.value"
@approve="nostrBridge.approveConsent"
@deny="nostrBridge.denyConsent"
/>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="showIdentityPicker = false"
@cancel="identity.cancelIdentitySelection"
/>
</div>
</div>
</Teleport>
</div>
</template>
@@ -105,6 +119,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -265,7 +280,12 @@ function closeRouteSession() {
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
appId: () => appId.value,
appName: () => appTitle.value,
appUrl: () => appUrl.value,
frameWindow: () => iframeRef.value?.contentWindow ?? null,
})
// --- Display mode ---
@@ -349,7 +369,7 @@ const backdropClasses = computed(() => {
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
const base = 'app-session-panel glass-card relative overflow-hidden'
if (inlinePanelMode.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
@@ -443,6 +463,7 @@ function handleBackdropClick() {
}
function closeSession() {
if (nostrBridge.showConsent.value) nostrBridge.denyConsent()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@@ -450,6 +471,11 @@ function closeSession() {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent()
e.preventDefault()
return
}
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
@@ -465,7 +491,7 @@ function onFullscreenChange() {
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
}
+4 -4
View File
@@ -637,10 +637,10 @@ function goToApp(id: string) {
async function launchApp(id: string) {
const shown = await maybeShowCredentialsBeforeLaunch(id)
if (shown) return
launchAppNow(id)
launchAppNow(id, true)
}
function launchAppNow(id: string) {
function launchAppNow(id: string, credentialsChecked = false) {
const pkg = packages.value[id]
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -664,7 +664,7 @@ function launchAppNow(id: string) {
return
}
}
useAppLauncherStore().openSession(id)
useAppLauncherStore().openSession(id, { skipCredentialPrompt: credentialsChecked })
}
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
@@ -721,7 +721,7 @@ function closeCredentialModal() {
function continueCredentialLaunch() {
const id = credentialModal.value.appId
closeCredentialModal()
if (id) launchAppNow(id)
if (id) launchAppNow(id, true)
}
async function copyModalCredential(label: string, value: string) {
+168 -22
View File
@@ -93,29 +93,18 @@
</div>
</div>
<!-- Hero + Featured + Banner (only when no search) -->
<!-- Hero + registry-owned storefront (only when no search) -->
<template v-if="!searchQuery">
<DiscoverHero
:total-apps="allApps.length"
:installed-count="installedCount"
/>
<FeaturedApps
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<!-- A registry storefront extends the featured story; it does not
replace it. Keep the primary banner above Popular, then place
registry promotions after the popular rows. -->
<div
v-if="featuredBanner"
v-if="catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
@@ -152,15 +141,136 @@
</div>
</div>
<!-- Mobile companion app banner — opens the download/pairing modal -->
<CompanionBanner />
<!-- New catalogs own the store composition. Six popular cards make two
rows on desktop; registry operators can reorder them without an OS
release. Older catalogs retain the legacy featured fallback. -->
<div v-if="catalogStorefront" class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">popular</span>
<h2 class="text-xl font-bold text-white">Popular Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ popularApps.length }} apps</span>
</div>
<AppGrid
v-if="catalogStorefront"
:filtered-apps="popularApps"
:show-stagger="showStagger"
:stagger-offset="0"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-installed-state="getInstalledState"
:get-app-tier="getAppTier"
:is-loading="loadingCommunity"
loading-message="Loading..."
:nostr-error="''"
:is-nostr-category="false"
:search-query="''"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<FeaturedApps
v-else
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<div
v-if="!catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
<img
:src="featuredBanner.banner"
:alt="featuredBanner.headline"
class="featured-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">featured</span>
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
<div class="flex items-center gap-3">
<button
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
@click.stop="launchInstalledApp(featuredBannerApp)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>Launch</button>
<button
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
@click.stop="handleInstall(featuredBannerApp)"
:disabled="installingApps.has(featuredBannerApp.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
<span v-else>Install</span>
</button>
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
</div>
</div>
</div>
<!-- Promotions are ordered and written by the registry catalog. -->
<div
v-for="promotion in storefrontPromotions"
:key="promotion.id"
class="featured-banner source-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="viewAppDetails(promotion.app)"
>
<img
:src="promotion.banner"
alt=""
class="featured-banner-img source-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay source-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
<span class="text-white/50 text-sm font-mono">{{ promotion.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ promotion.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ promotion.description }}</p>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="isInstalled(promotion.app.id) && !isStartingUp(promotion.app.id)"
@click.stop="launchInstalledApp(promotion.app)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>{{ promotion.launchLabel || 'Launch' }}</button>
<button
v-else-if="!isInstalled(promotion.app.id) && promotion.app.dockerImage"
@click.stop="handleInstall(promotion.app)"
:disabled="installingApps.has(promotion.app.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>{{ installingApps.has(promotion.app.id) ? 'Installing…' : (promotion.installLabel || 'Install') }}</button>
<button
@click.stop="viewAppDetails(promotion.app)"
class="rounded-lg px-4 py-2.5 text-sm font-medium text-white/70 hover:text-white"
>{{ promotion.detailsLabel || 'View details →' }}</button>
</div>
</div>
</div>
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
<h2 class="text-xl font-bold text-white">Available to Install</h2>
<h2 class="text-xl font-bold text-white">All Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
<span class="text-white/30 text-sm">{{ remainingApps.length }} apps</span>
</div>
</template>
@@ -179,7 +289,7 @@
</div>
<AppGrid
:filtered-apps="filteredApps"
:filtered-apps="gridApps"
:show-stagger="showStagger"
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
:containers-scanned="containersScanned"
@@ -199,6 +309,10 @@
@retry-nostr="retryNostr"
/>
<!-- The node-owned companion download is operational UI, not registry
merchandising, so it follows the complete app listing. -->
<CompanionBanner v-if="!searchQuery" />
<!-- Manifesto Footer (only when no search) -->
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
<div class="flex items-center gap-3 mb-4">
@@ -250,7 +364,7 @@ import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
const router = useRouter()
const store = useAppStore()
@@ -299,6 +413,12 @@ const catalogFeatured = useCachedResource<CatalogFeatured | null>({
ttlMs: 300_000,
persist: true,
}).data
const catalogStorefront = useCachedResource<CatalogStorefront | null>({
key: 'app-catalog:storefront',
fetcher: async () => (await fetchAppCatalog())?.storefront ?? null,
ttlMs: 300_000,
persist: true,
}).data
const communityApps = computed(() => catalogResource.data.value ?? [])
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
@@ -452,6 +572,32 @@ const filteredApps = computed(() => {
return apps
})
const popularApps = computed(() => {
const byId = new Map(allApps.value.map(app => [app.id, app]))
const popular: MarketplaceApp[] = []
for (const id of catalogStorefront.value?.popular ?? []) {
const app = byId.get(id)
if (app?.dockerImage) popular.push(app)
}
return popular
})
const popularIds = computed(() => new Set(popularApps.value.map(app => app.id)))
const remainingApps = computed(() =>
catalogStorefront.value
? filteredApps.value.filter(app => !popularIds.value.has(app.id))
: filteredApps.value
)
const gridApps = computed(() => searchQuery.value ? filteredApps.value : remainingApps.value)
const storefrontPromotions = computed(() => {
if (!catalogStorefront.value) return []
return catalogStorefront.value.promotions.flatMap(promotion => {
const app = allApps.value.find(candidate => candidate.id === promotion.id)
return app ? [{ ...promotion, app }] : []
})
})
const installedCount = computed(() => {
return allApps.value.filter(app => isInstalled(app.id)).length
})
@@ -497,6 +497,15 @@ function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefin
// Placeholder features
const features = computed(() => {
if (appId.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
+221
View File
@@ -0,0 +1,221 @@
<template>
<main class="relative h-screen w-screen overflow-hidden bg-transparent" aria-label="Archipelago Nostr signer">
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appName"
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
<NostrSignConsent
:show="bridge.showConsent.value"
:app-name="bridge.consentRequest.value?.appName ?? appName"
:method="bridge.consentRequest.value?.method ?? ''"
:identity-label="bridge.consentRequest.value?.identityLabel"
:event-kind="bridge.consentRequest.value?.eventKind"
:content="bridge.consentRequest.value?.content"
:phase="bridge.consentPhase.value"
:error="bridge.consentError.value"
@approve="bridge.approveConsent"
@deny="bridge.denyConsent"
/>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
const appId = ref('')
const appName = ref('App')
const appOrigin = ref('')
const showIdentityPicker = ref(false)
const queuedRequests: MessageEvent[] = []
let hideTimer: ReturnType<typeof setTimeout> | null = null
function getStoredIdentity(): SelectedIdentity | null {
if (!appId.value) return null
try {
const raw = localStorage.getItem(`archipelago_app_identity_${appId.value}`)
return raw ? JSON.parse(raw) as SelectedIdentity : null
} catch {
return null
}
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(`archipelago_app_identity_${appId.value}`, JSON.stringify(identity)) } catch {}
}
function parentPost(message: Record<string, unknown>) {
window.parent.postMessage(message, appOrigin.value || '*')
}
function showSigner() {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
parentPost({ type: 'archipelago:signer-show' })
}
function hideSigner(delay = 0) {
if (hideTimer !== null) clearTimeout(hideTimer)
const hide = () => {
hideTimer = null
if (!showIdentityPicker.value && !bridge.showConsent.value) {
parentPost({ type: 'archipelago:signer-hide' })
}
}
if (delay > 0) hideTimer = setTimeout(hide, delay)
else hide()
}
function sendIdentity(identity: SelectedIdentity) {
parentPost({ type: 'archipelago:signer-identity', identity })
}
const bridge = useNostrBridge(getStoredIdentity, {
appId: () => appId.value,
appName: () => appName.value,
appUrl: () => appOrigin.value,
frameWindow: () => window.parent,
})
// Keep the app-side broker frame hidden for silent/remembered requests. It is
// a full-viewport iframe in tabs and Companion WebViews, so showing it for
// every background getPublicKey/getRelays call produces a visible flash even
// when no consent card opens. Reveal it only when there is actually something
// for the user to review, and keep it visible through signing/result.
watch(bridge.showConsent, (show) => {
if (show) showSigner()
else if (!showIdentityPicker.value) hideSigner()
})
function isSameNodeAppOrigin(origin: string): boolean {
try {
const candidate = new URL(origin)
return (candidate.protocol === 'http:' || candidate.protocol === 'https:')
&& candidate.hostname === window.location.hostname
} catch {
return false
}
}
async function onIdentitySelected(identity: SelectedIdentity) {
storeIdentity(identity)
showIdentityPicker.value = false
sendIdentity(identity)
const requests = queuedRequests.splice(0)
if (requests.length) {
await nextTick()
for (const request of requests) await handleRequest(request)
} else {
// The host app normally follows the selected identity with getPublicKey
// and signEvent. Keep one continuous signer surface through that chain so
// the picker does not disappear and immediately flash back as consent.
hideSigner(400)
}
}
async function handleRequest(event: MessageEvent) {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
await bridge.handleNostrRequest(event)
// getRelays and remembered approvals complete without opening the consent
// card. Do not leave the otherwise-transparent broker intercepting the app.
if (!bridge.showConsent.value && !showIdentityPicker.value) {
hideSigner()
}
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
const requests = queuedRequests.splice(0)
for (const request of requests) {
const id = (request.data as { id?: unknown } | null)?.id
window.parent.postMessage({ type: 'nostr-response', id, error: 'Identity selection cancelled' }, request.origin)
}
parentPost({ type: 'archipelago:signer-identity-cancelled' })
hideSigner()
}
function onMessage(event: MessageEvent) {
if (event.source !== window.parent) return
const data = event.data as Record<string, unknown> | null
if (!data) return
if (data.type === 'archipelago:signer-init') {
const id = typeof data.appId === 'string' ? data.appId : ''
const name = typeof data.appName === 'string' ? data.appName : 'App'
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id) || !isSameNodeAppOrigin(event.origin)) return
appId.value = id
appName.value = name.slice(0, 120)
appOrigin.value = event.origin
const stored = getStoredIdentity()
if (stored) {
sendIdentity(stored)
hideSigner(400)
} else {
showIdentityPicker.value = true
showSigner()
}
return
}
if (data.type === 'archipelago:signer-select-identity' && appId.value && event.origin === appOrigin.value) {
showIdentityPicker.value = true
showSigner()
return
}
if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return
if (!getStoredIdentity()) {
queuedRequests.push(event)
showIdentityPicker.value = true
showSigner()
return
}
void handleRequest(event)
}
onMounted(() => {
// This route is rendered inside a full-viewport broker iframe. The global
// dashboard stylesheet gives body a solid black canvas and animated
// compositor layers; Android WebView can retain that last iframe surface
// for a frame (or indefinitely) after the picker closes. Keep the broker's
// document genuinely transparent so even a stale surface cannot cover the
// app beneath it.
document.documentElement.classList.add('nostr-signer-route')
document.body.classList.add('nostr-signer-route')
window.addEventListener('message', onMessage)
window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
})
onBeforeUnmount(() => {
if (hideTimer !== null) clearTimeout(hideTimer)
window.removeEventListener('message', onMessage)
document.documentElement.classList.remove('nostr-signer-route')
document.body.classList.remove('nostr-signer-route')
})
</script>
<style>
html.nostr-signer-route,
html.nostr-signer-route body,
html.nostr-signer-route #app {
background: transparent !important;
}
html.nostr-signer-route body::before,
html.nostr-signer-route body::after,
html.nostr-signer-route::before {
content: none !important;
animation: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
</style>
+8 -3
View File
@@ -914,9 +914,10 @@ async function loadStatus() {
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
}
// Mirror the backend in both directions. The old one-way assignment could
// set this after a completed download but never clear it after cancellation,
// leaving the Install button visible until the component remounted.
downloaded.value = res.update_in_progress
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
}
@@ -1043,6 +1044,10 @@ async function cancelDownload() {
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
// `update_in_progress` is the backend's staged/installable flag. Leaving
// this true made the card render Install until the next page refresh even
// though cancellation had already removed the partial staging files.
updateInProgress.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
@@ -41,7 +41,15 @@ vi.mock('../appSession/useAppIdentity', () => ({
}))
vi.mock('../appSession/useNostrBridge', () => ({
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
useNostrBridge: () => ({
handleNostrRequest: vi.fn(),
showConsent: { value: false },
consentRequest: { value: null },
consentPhase: { value: 'review' },
consentError: { value: '' },
approveConsent: vi.fn(),
denyConsent: vi.fn(),
}),
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -0,0 +1,49 @@
import { shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
describe('NostrTabSigner visibility', () => {
beforeEach(() => {
localStorage.clear()
window.history.replaceState({}, '', '/nostr-signer')
})
afterEach(() => vi.restoreAllMocks())
function parentMessage(data: Record<string, unknown>) {
const event = new MessageEvent('message', { data, origin: window.location.origin })
Object.defineProperty(event, 'source', { value: window.parent })
window.dispatchEvent(event)
}
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
name: 'Alice',
nostr_pubkey: 'abc123',
}))
const postMessage = vi.spyOn(window.parent, 'postMessage')
const wrapper = shallowMount(NostrTabSigner)
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(true)
expect(document.body.classList.contains('nostr-signer-route')).toBe(true)
parentMessage({
type: 'archipelago:signer-init',
appId: 'archipelago-source',
appName: 'GitWorkshop',
})
postMessage.mockClear()
parentMessage({ type: 'nostr-request', id: 1, method: 'getRelays', params: {} })
await Promise.resolve()
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-show' }),
expect.anything(),
)
wrapper.unmount()
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
@@ -45,6 +45,11 @@ describe('appSessionConfig', () => {
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('does not force GitWorkshop into a dashboard iframe in Companion', () => {
expect(GENERATED_HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
expect(HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
@@ -147,4 +152,17 @@ describe('appSessionConfig', () => {
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
})
it('keeps the pre-catalog Source app on the dashboard origin', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
// Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search')
})
})
@@ -0,0 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { consentKey, hasRememberedConsent, rememberConsent } from '../nostrConsent'
describe('NIP-07 consent storage', () => {
beforeEach(() => localStorage.clear())
it('binds remembered approval to origin, app, identity and method', () => {
const key = consentKey('https://node.example', 'archipelago-source', 'identity-a', 'signEvent')
rememberConsent(key)
expect(hasRememberedConsent(key)).toBe(true)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-b', 'signEvent'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-a', 'nip44.decrypt'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://other-node.example', 'archipelago-source', 'identity-a', 'signEvent'))).toBe(false)
})
})
@@ -0,0 +1,314 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
type ProviderWindow = Window & {
__archipelagoNostr?: boolean
ArchipelagoSurface?: {
expectPageTransition: () => void
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
callback: (identity: { nostr_pubkey: string }) => void,
) => () => void
}
}
describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow
beforeEach(() => {
providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
document.documentElement.innerHTML = '<head><title>IndeedHub</title></head><body></body>'
window.history.replaceState({}, '', '/app/indeedhub/')
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
Reflect.deleteProperty(document, 'readyState')
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
})
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: userActivated },
})
window.eval(providerSource)
window.dispatchEvent(new Event('load'))
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
postMessage.mockClear()
return { frame, postMessage, signerOrigin }
}
it('reopens the chooser for a user-triggered NIP-07 login', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const publicKey = providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }),
signerOrigin,
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('uses the remembered identity for background account restoration', () => {
const { postMessage, signerOrigin } = loadProvider(false)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }),
expect.anything(),
)
})
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = {
expectPageTransition: vi.fn(),
}
providerWindow.ArchipelagoSurface = surface
const { frame, postMessage, signerOrigin } = loadProvider(false)
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
expect(frame.style.display).toBe('block')
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
expect(frame.style.display).toBe('block')
expect(frame.style.width).toBe('1px')
expect(frame.style.height).toBe('1px')
expect(frame.style.opacity).toBe('0')
expect(frame.style.pointerEvents).toBe('none')
expect(frame.style.transform).toContain('-10000px')
expect(document.querySelector('#archipelago-nostr-signer')).toBe(frame)
const publicKey = providerWindow.nostr!.getPublicKey()
const replacement = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
expect(replacement).toBe(frame)
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: replacement.contentWindow })
window.dispatchEvent(ready)
const request = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
const listener = vi.fn()
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
window.dispatchEvent(changed)
expect(listener).toHaveBeenCalledOnce()
})
it('turns an automatic IndeeHub identity into a NIP-98 signing request', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/nostr-auth/health`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
}),
signerOrigin,
)
})
it('waits for the signer success surface to hide before reloading after NIP-98', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
const signRequest = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request' && message.id != null)!
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
await Promise.resolve()
expect(raf).toHaveBeenCalledOnce()
})
it('queues a request until signer-init when the signer iframe wins the load race', () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: false },
})
Object.defineProperty(document, 'readyState', {
configurable: true,
value: 'loading',
})
window.eval(providerSource)
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalled()
window.dispatchEvent(new Event('load'))
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
})
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useAppIdentity, type SelectedIdentity } from '../useAppIdentity'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const alice: SelectedIdentity = {
id: 'alice-id',
name: 'Alice',
did: 'did:key:alice',
pubkey: 'identity-key',
nostr_pubkey: 'nostr-key',
}
describe('useAppIdentity explicit identity selection', () => {
beforeEach(() => {
localStorage.clear()
vi.mocked(rpcClient.call).mockResolvedValue({ signature: 'proof' })
})
it('reuses a stored identity normally but reopens the picker for login', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify(alice))
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(false)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.handleIdentityRequest()
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:identity', nostr_pubkey: 'nostr-key' }),
'*',
))
postMessage.mockClear()
identity.handleIdentityRequest(true)
expect(showPicker.value).toBe(true)
expect(postMessage).not.toHaveBeenCalled()
})
it('notifies the requesting app when the chooser is cancelled', () => {
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(true)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.cancelIdentitySelection()
expect(showPicker.value).toBe(false)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:identity-cancelled' }, '*')
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useNostrBridge } from '../useNostrBridge'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('useNostrBridge consent presentation', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'signed-event' })
})
afterEach(() => vi.useRealTimers())
it('keeps the contained identity loader visible through signing and completion', async () => {
const source = { postMessage: vi.fn() } as unknown as Window
const bridge = useNostrBridge(
() => ({ id: 'identity-a', name: 'Alice', nostr_pubkey: 'pubkey-a' } as never),
{
appId: () => 'archipelago-source', appName: () => 'GitWorkshop',
appUrl: () => 'https://node.test/app/archipelago-source/', frameWindow: () => source,
},
)
const event = {
data: { type: 'nostr-request', id: 'request-1', method: 'signEvent', params: { event: { kind: 1621, content: 'Fix it' } } },
source, origin: 'https://node.test',
} as MessageEvent
const handling = bridge.handleNostrRequest(event)
await Promise.resolve()
expect(bridge.showConsent.value).toBe(true)
expect(bridge.consentPhase.value).toBe('review')
bridge.approveConsent(false)
expect(bridge.consentPhase.value).toBe('signing')
expect(bridge.showConsent.value).toBe(true)
await handling
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'nostr-response', id: 'request-1' }), 'https://node.test')
await vi.advanceTimersByTimeAsync(350)
expect(bridge.consentPhase.value).toBe('success')
await vi.advanceTimersByTimeAsync(325)
expect(bridge.showConsent.value).toBe(false)
})
})
@@ -1,7 +1,12 @@
/** Static configuration maps for app session routing and display */
import { portIsGateFronted } from '../discover/curatedApps'
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import {
GENERATED_APP_PORTS,
GENERATED_APP_TITLES,
GENERATED_HOST_FRAME_APPS,
GENERATED_NEW_TAB_APPS,
} from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
@@ -50,6 +55,7 @@ export const APP_PORTS: Record<string, number> = {
/** Apps that need nginx proxy for iframe embedding.
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'archipelago-source': '/app/archipelago-source/',
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
@@ -59,6 +65,21 @@ export const PROXY_APPS: Record<string, string> = {
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
/**
* First-party apps that are deliberately being node-tested before their
* manifest reaches the release-signed catalog. Keep this list narrow: it is
* only a scheme-routing fallback, and does not make an app installable or
* trusted. Once the signed catalog carries the app, portIsGateFronted is the
* normal source of truth.
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
return portIsGateFronted(appId, port) || PRE_CATALOG_GATED_PORTS[appId] === Number(port)
}
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nostrudel': 'https://nostrudel.ninja',
@@ -81,6 +102,13 @@ export const NEW_TAB_APPS = new Set([
'tailscale',
])
/** Apps that consume an integration supplied by the dashboard parent frame.
* The Android companion normally promotes sessions into a top-level native
* WebView; doing that to one of these apps would sever its postMessage bridge. */
export const HOST_FRAME_APPS = new Set([
...GENERATED_HOST_FRAME_APPS,
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
@@ -103,6 +131,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// GitWorkshop is deliberately mounted below the dashboard origin. This is
// the only launch shape that survives every supported ingress (LAN,
// Tailscale, FIPS, Tor and reverse proxies) without assuming that a second
// high port is reachable through the same address.
if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
}
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
@@ -120,7 +158,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// would fail to connect over https at all.
try {
const port = new URL(base).port
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
if (appPortIsGateFronted(id, port)) base = matchPageScheme(base)
} catch { /* keep as-is */ }
if (routeQueryPath) base += routeQueryPath
return base
@@ -152,7 +190,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
*/
export function appOrigin(port: number, appId?: string): string {
const https = appId
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
? HTTPS_APP_IDS.has(appId) || (appPortIsGateFronted(appId, port) && pageScheme() === 'https:')
: pageScheme() === 'https:'
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
}
@@ -4,6 +4,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"adguardhome": 3030,
"aiui": 5180,
"alby-hub": 8187,
"archipelago-source": 8337,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -42,6 +43,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"adguardhome": "AdGuard Home",
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archipelago-source": "GitWorkshop",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -114,3 +116,6 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"uptime-kuma",
"vaultwarden",
])
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
])
@@ -0,0 +1,30 @@
const CONSENT_KEY = 'archipelago_nostr_consent_v2'
function readRemembered(): Set<string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(CONSENT_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [])
} catch {
return new Set()
}
}
/** Remembered NIP-07 access is scoped to the exact app, identity and method. */
export function consentKey(
origin: string,
appId: string,
identityId: string,
method: string,
): string {
return JSON.stringify(['v2', origin, appId, identityId, method])
}
export function hasRememberedConsent(key: string): boolean {
return readRemembered().has(key)
}
export function rememberConsent(key: string): void {
const remembered = readRemembered()
remembered.add(key)
try { localStorage.setItem(CONSENT_KEY, JSON.stringify([...remembered])) } catch { /* unavailable/full */ }
}
@@ -16,7 +16,7 @@ export interface SelectedIdentity {
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
return id === 'indeedhub' || id === 'nostrudel' || id === 'archipelago-source'
}
export function useAppIdentity(
@@ -68,18 +68,24 @@ export function useAppIdentity(
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
function handleIdentityRequest(force = false) {
if (IS_DEMO) return
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
if (stored && !force) sendIdentity(stored)
else showIdentityPicker.value = true
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
return {
getStoredIdentity,
sendIdentity,
onIdentitySelected,
onIframeLoadIdentity,
handleIdentityRequest,
cancelIdentitySelection,
}
}
+156 -30
View File
@@ -1,31 +1,147 @@
/** Composable for NIP-07 Nostr signing between parent and iframe apps.
*
* Replies always target event.origin — the frame's REAL origin. The app's
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
* dashboard); targeting it makes postMessage throw and the app never sees
* its response. */
/** Consent-gated NIP-07 bridge between the dashboard and an iframe app. */
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from './nostrConsent'
interface BridgeOptions {
appId: () => string
appName: () => string
appUrl: () => string
frameWindow: () => Window | null
}
export interface BridgeConsentRequest {
appName: string
method: string
identityLabel: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
const CONSENT_METHODS = new Set([
'getPublicKey', 'signEvent',
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
function senderMatches(expectedUrl: string, senderOrigin: string): boolean {
try {
const expected = new URL(expectedUrl, window.location.origin)
const sender = new URL(senderOrigin)
return expected.hostname === sender.hostname && expected.port === sender.port
} catch {
return false
}
}
export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null,
options: BridgeOptions,
) {
const consentRequest = ref<BridgeConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
function requestConsent(
method: string,
identityLabel: string,
eventKind?: number,
content?: string,
): Promise<boolean> {
return new Promise((resolve, reject) => {
consentGeneration += 1
consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
function approveConsent(remember: boolean) {
consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
consentRequest.value?.reject()
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
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'
}
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
if (
!source ||
source !== options.frameWindow() ||
!senderMatches(options.appUrl(), event.origin)
) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin
let prompted = false
try {
if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) {
prompted = true
const remember = await requestConsent(
method,
identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : undefined,
)
if (remember) rememberConsent(key)
}
}
let result: unknown
if (method === 'getPublicKey') {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
@@ -34,30 +150,40 @@ export function useNostrBridge(
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
result = identityId
? await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
: await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip04.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else if (method === 'nip44.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip44.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
if (import.meta.env.DEV) console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
// Reply to the sender's REAL origin, never to the stored app URL:
// a scheme-upgraded frame (HSTS, or any future upgrade) makes the
// stored http:// URL a stale targetOrigin — postMessage then throws
// and the app never receives its response. nostr sign-in on IndeeHub
// over HTTPS died exactly there (2026-09-01).
source.postMessage({ type: 'nostr-response', id, result }, event.origin || '*')
source.postMessage({ type: 'nostr-response', id, result }, origin)
if (prompted) void finishConsentSuccess()
} catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, event.origin || '*')
source.postMessage({
type: 'nostr-response', id,
error: err instanceof Error ? err.message : 'Unknown error',
}, origin)
if (prompted && showConsent.value) finishConsentError(err)
}
}
return { handleNostrRequest }
return {
handleNostrRequest,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
}
+4 -4
View File
@@ -222,7 +222,7 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
if (shown) return
launchNow(id, pkg)
launchNow(id, pkg, true)
} else {
emit('goToApp', id)
}
@@ -248,7 +248,7 @@ function openAppOptions(id: string) {
emit('goToApp', id)
}
function launchNow(id: string, pkg: PackageDataEntry) {
function launchNow(id: string, pkg: PackageDataEntry, credentialsChecked = false) {
markLaunching(id)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -270,7 +270,7 @@ function launchNow(id: string, pkg: PackageDataEntry) {
return
}
}
appLauncher.openSession(id)
appLauncher.openSession(id, { skipCredentialPrompt: credentialsChecked })
}
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
@@ -308,7 +308,7 @@ function continueCredentialLaunch() {
const id = credentialModal.value.appId
const entry = props.apps.find(([appId]) => appId === id)
closeCredentialModal()
if (entry) launchNow(entry[0], entry[1])
if (entry) launchNow(entry[0], entry[1], true)
}
async function copyModalCredential(label: string, value: string) {
+31 -2
View File
@@ -12,10 +12,31 @@ export interface CatalogFeatured {
tag: string
}
/** Registry-owned App Store ordering and promotions. Keeping this alongside
* the app entries lets a catalog release change merchandising without an OS
* or dashboard release. */
export interface CatalogPromotion {
id: string
banner: string
eyebrow: string
headline: string
description: string
tag: string
launchLabel?: string
installLabel?: string
detailsLabel?: string
}
export interface CatalogStorefront {
popular: string[]
promotions: CatalogPromotion[]
}
export interface AppCatalog {
version: number
registry: string
featured: CatalogFeatured
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: MarketplaceApp[]
}
@@ -28,6 +49,8 @@ export interface AppCatalog {
export interface SignedAppCatalog {
schema?: number
updated?: string
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: Record<string, SignedAppEntry>
}
@@ -171,6 +194,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// dashboard release. The community catalog supplies the featured banner
// and curated copy for shared ids; signed-only ids join the listing as-is.
let signedApps: MarketplaceApp[] = []
let signedFeatured: CatalogFeatured | undefined
let signedStorefront: CatalogStorefront | undefined
let signedOk = false
try {
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
@@ -179,6 +204,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
if (data.apps && !Array.isArray(data.apps)) {
signedCatalogCache = data
signedApps = signedCatalogToApps(data)
signedFeatured = data.featured
signedStorefront = data.storefront
signedOk = signedApps.length > 0
}
}
@@ -214,7 +241,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()],
}
cachedCatalog = merged
@@ -269,6 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
{ id: 'archipelago-source', title: 'GitWorkshop', version: '0.4.0', category: 'development', 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', dockerImage: 'localhost/archipelago-source:local', repoUrl: 'https://github.com/DanConwayDev/gitworkshop' },
]
}
+1
View File
@@ -18,6 +18,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
containerConfig?: ContainerConfig
requires?: string[]
tier?: string
maintainerNpub?: string
}
export type FeaturedApp = MarketplaceApp & {
@@ -438,5 +438,16 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://gitea.com',
},
{
id: 'archipelago-source',
title: 'GitWorkshop',
version: '0.4.0',
category: 'development',
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',
dockerImage: 'localhost/archipelago-source:local',
repoUrl: 'https://github.com/DanConwayDev/gitworkshop',
},
]
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
@@ -73,6 +74,15 @@ async function probe() {
}
}
function downloadCertificate(event: MouseEvent) {
// Android WebView does not implement HTML downloads by itself. Ask the
// companion to fetch this connected node's CA and open the system credential
// installer; normal browsers keep the Content-Disposition download.
if (installCertificateInCompanion()) {
event.preventDefault()
}
}
onMounted(probe)
</script>
@@ -109,6 +119,7 @@ onMounted(probe)
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
@click="downloadCertificate"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -132,29 +143,85 @@ onMounted(probe)
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> — open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set “When using this certificate” to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> — download it in Safari and
allow the profile, then Settings → General → VPN &amp; Device Management to
install it, and finally Settings → General → About → Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> — right-click → Install
Certificate → Local Machine → place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> — Settings → Security →
Encryption &amp; credentials → Install a certificate → CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> — copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store — add it under Settings → Privacy &amp; Security →
View Certificates → Authorities.</p>
<p class="text-white/50">
<div class="mt-2 space-y-5 text-sm text-white/60">
<p class="text-white/70">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key — remove the certificate from your devices if you retire it.
</p>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">macOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Double-click the file to add it to your <em>login keychain</em>.</li>
<li>Open Keychain Access and find it under Certificates.</li>
<li>Open it, expand Trust, set “When using this certificate” to <em>Always Trust</em>, then close the window and enter your password.</li>
</ol>
<p>Quit and reopen your browser after changing the trust setting.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">iOS / iPadOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Open the file in Safari and tap Allow to download the profile.</li>
<li>Settings → Profile Downloaded, or General → VPN &amp; Device Management → Install.</li>
<li>Settings → General → About → Certificate Trust Settings → switch the certificate on.</li>
</ol>
<p class="text-orange-200/80">The final Certificate Trust Settings step is required.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Windows</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Right-click the file and choose Install Certificate.</li>
<li>Select Local Machine.</li>
<li>Choose “Place all certificates in the following store” → Trusted Root Certification Authorities → Finish.</li>
</ol>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Android</h4>
<p>Settings → Security → Encryption &amp; credentials → Install a certificate → CA certificate, then choose the file.</p>
<p>Browsers using the system certificate store will trust it after restart. Apps that pin their own certificates may still refuse it.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Linux</h4>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo install -m644 /path/to/node-ca.crt /usr/local/share/ca-certificates/node-ca.crt &amp;&amp; sudo update-ca-certificates</code></pre>
<p><strong class="text-white/70">Firefox:</strong> Settings → Privacy &amp; Security → View Certificates → Authorities → Import, then enable “Trust this CA to identify websites”.</p>
<p><strong class="text-white/70">Arch / Manjaro:</strong></p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo cp node-ca.crt /etc/ca-certificates/trust-source/anchors/ &amp;&amp; sudo update-ca-trust extract</code></pre>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Restart the browser first</h4>
<p>Chrome, Brave, Firefox, and Safari cache certificate decisions. Fully quit and reopen the browser before troubleshooting a certificate that still appears untrusted.</p>
<p>For a one-visit sanity check on a machine you own, Chrome and Brave accept the keyboard shortcut <code class="px-1 py-0.5 bg-black/30 rounded text-xs">thisisunsafe</code> on the certificate error page. Use this only for testing.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">If the node name does not resolve</h4>
<p>Certificate trust and DNS are separate. If <code class="px-1 py-0.5 bg-black/30 rounded text-xs">node.local</code> does not resolve, prefer the node's Tailscale MagicDNS name when available.</p>
<p>To keep using a local name on Linux or macOS, add the node address to <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>:</p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>echo '192.168.x.y mynode.local' | sudo tee -a /etc/hosts</code></pre>
<p>On Linux, if that still fails, inspect <code class="px-1 py-0.5 bg-black/30 rounded text-xs">grep '^hosts:' /etc/nsswitch.conf</code>. Put <code class="px-1 py-0.5 bg-black/30 rounded text-xs">files</code> before <code class="px-1 py-0.5 bg-black/30 rounded text-xs">mdns_minimal [NOTFOUND=return]</code> so an mDNS miss cannot block <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Symptoms</h4>
<div class="overflow-x-auto rounded-lg border border-white/10">
<table class="w-full text-left text-xs">
<thead class="bg-white/5 text-white/75">
<tr><th class="p-2">What you see</th><th class="p-2">Likely cause</th></tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr><td class="p-2">Not trusted / ERR_CERT_AUTHORITY_INVALID</td><td class="p-2">The certificate is not installed, or the browser was not restarted.</td></tr>
<tr><td class="p-2">This site can't be reached / DNS error</td><td class="p-2">Name resolution, not TLS. Check the DNS guidance above.</td></tr>
<tr><td class="p-2">curl works, browser does not</td><td class="p-2">A separate browser certificate store or a stale browser process.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</details>
</div>
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NodeCertificateSection from '../NodeCertificateSection.vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
vi.mock('@/utils/openExternal', () => ({
installCertificateInCompanion: vi.fn(),
}))
const installCertificate = vi.mocked(installCertificateInCompanion)
describe('NodeCertificateSection', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => '-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----',
}))
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('uses the native installer and cancels WebView navigation in the companion', async () => {
installCertificate.mockReturnValue(true)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
expect(installCertificate).toHaveBeenCalledOnce()
expect(click.defaultPrevented).toBe(true)
})
it('preserves the ordinary browser download when no native installer exists', async () => {
installCertificate.mockReturnValue(false)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
const componentPreservedDownload = !click.defaultPrevented
expect(installCertificate).toHaveBeenCalledOnce()
expect(componentPreservedDownload).toBe(true)
})
it('includes the complete trust, browser restart, DNS, and troubleshooting guidance', async () => {
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('details').exists()).toBe(true))
const text = wrapper.text()
expect(text).toContain('Certificate Trust Settings')
expect(text).toContain('Trusted Root Certification Authorities')
expect(text).toContain('update-ca-trust extract')
expect(text).toContain('Restart the browser first')
expect(text).toContain('thisisunsafe')
expect(text).toContain('Tailscale MagicDNS')
expect(text).toContain("This site can't be reached / DNS error")
expect(text).toContain('curl works, browser does not')
})
})
+53 -25
View File
@@ -392,6 +392,17 @@
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<IdentitySuccessPane
v-if="profileSuccess"
:identity-name="profileSuccess.identityName"
:event-id="profileSuccess.eventId"
:accepted="profileSuccess.accepted"
:attempted="profileSuccess.attempted"
:relay-note="profileSuccess.relayNote"
@again="profileSuccess = null"
@done="closeProfileEditor"
/>
<template v-else>
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img
@@ -455,11 +466,11 @@
</div>
</div>
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -471,6 +482,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ManagedIdentity, IdentityProfile } from './types'
import IdentitySuccessPane from '@/components/IdentitySuccessPane.vue'
const { t } = useI18n()
@@ -618,7 +630,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
}
}
const profileError = ref('')
const profileSuccess = ref('')
interface ProfilePublishSuccess {
identityName: string
eventId: string
accepted: number
attempted: number
relayNote: string
}
const profileSuccess = ref<ProfilePublishSuccess | null>(null)
async function loadIdentities() {
const hadIdentities = managedIdentities.value.length > 0
@@ -739,58 +758,67 @@ function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
try {
const identity = profileEditorIdentity.value
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
params: { id: identity.id, ...profileForm.value },
})
await loadIdentities()
try {
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
})
await loadIdentities()
const n = res.accepted?.length ?? 0
const total = res.relays_attempted ?? 0
const tail = `(${res.event_id.slice(0, 12)}…)`
if (n === total) {
profileSuccess.value = `Published to all ${total} relays ${tail}`
} else if (n > 0) {
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
const first = res.rejected?.[0]
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
} else {
profileError.value = `Published to 0/${total} relays — check Manage Relays`
}>({ method: 'identity.publish-profile', params: { id: identity.id } })
const accepted = res.accepted?.length ?? 0
const attempted = res.relays_attempted ?? 0
const rejected = res.rejected?.[0]
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: res.event_id || '',
accepted,
attempted,
relayNote: accepted === attempted
? ''
: rejected
? `${rejected[0]} rejected the event: ${rejected[1]}`
: 'The profile is saved on this node. Check Manage Relays before retrying publication.',
}
} catch (publishError: unknown) {
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: '',
accepted: 0,
attempted: 0,
relayNote: `The profile is saved on this node, but relay publication failed: ${publishError instanceof Error ? publishError.message : 'unknown error'}`,
}
}
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
profileError.value = err instanceof Error ? err.message : 'Failed to save profile'
} finally {
profilePublishing.value = false
}
}
defineExpose({ loadIdentities, managedIdentities })
defineExpose({ loadIdentities, managedIdentities, openProfileEditor, publishProfile })
</script>
<style scoped>
@@ -5,6 +5,19 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<PaymentSuccessPane
v-if="sendSuccess"
:amount="sendSuccess.amount"
verb="SENT"
:method-label="sendSuccess.methodLabel"
:rows="sendSuccess.rows"
:note="sendSuccess.note"
again-label="Send another"
@again="sendAnother"
@done="closeUnifiedSendModal"
/>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -32,12 +45,6 @@
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
@@ -93,8 +100,6 @@
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
@@ -106,6 +111,7 @@
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -177,7 +183,6 @@
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
@@ -233,7 +238,12 @@ const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
const ecashSendToken = ref('')
const sendSuccess = ref<{
amount: number
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
// Receive state
const showUnifiedReceiveModal = ref(false)
@@ -246,7 +256,6 @@ const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
const ecashReceiveToken = ref('')
const ecashReceiveResult = ref('')
// Details of the last successful ecash receive, for the success screen.
// Null = nothing to celebrate yet, so the form shows.
const ecashSuccess = ref<{
@@ -265,12 +274,15 @@ const effectiveSendMethod = computed(() => {
return 'lightning'
})
function openSend() { showUnifiedSendModal.value = true }
function openSend() {
sendSuccess.value = null
showUnifiedSendModal.value = true
}
function openReceive() { showUnifiedReceiveModal.value = true }
function closeUnifiedSendModal() {
showUnifiedSendModal.value = false
ecashSendToken.value = ''
sendSuccess.value = null
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
@@ -284,16 +296,10 @@ function closeUnifiedReceiveModal() {
receiveInvoiceResult.value = ''
receiveOnchainAddress.value = ''
ecashReceiveToken.value = ''
ecashReceiveResult.value = ''
ecashSuccess.value = null
unifiedReceiveError.value = ''
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
emit('toast', t('web5.ecashTokenCopied'))
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
emit('toast', msg)
@@ -303,7 +309,7 @@ async function unifiedSend() {
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
ecashSendToken.value = ''
sendSuccess.value = null
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
@@ -316,7 +322,16 @@ async function unifiedSend() {
method: 'wallet.ecash-send',
params: { amount_sats: unifiedSendAmount.value },
})
ecashSendToken.value = res.token
sendSuccess.value = {
amount: unifiedSendAmount.value,
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 === 'lightning') {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.pasteInvoice')
@@ -327,6 +342,14 @@ async function unifiedSend() {
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
sendSuccess.value = {
amount: res.amount_sats || unifiedSendAmount.value,
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
rows: res.payment_hash ? [{ label: 'Payment hash', value: res.payment_hash }] : [],
...(res.status === 'pending'
? { note: 'This payment is taking longer than usual to settle. Check transactions before retrying.' }
: {}),
}
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
@@ -354,6 +377,12 @@ async function unifiedSend() {
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain',
rows: [{ label: 'Transaction ID', value: res.txid }],
note: 'The transaction has been broadcast and will confirm over the next blocks.',
}
} catch (sendErr: unknown) {
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
@@ -416,6 +445,12 @@ function startMeshRelayPolling(_requestId: number) {
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain over mesh',
rows: [{ label: 'Transaction ID', value: match[1] }],
note: 'Broadcast confirmed. The transaction is waiting for block confirmations.',
}
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
}
}
@@ -454,6 +489,12 @@ async function finalizePsbt() {
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain with hardware wallet',
rows: [],
note: t('web5.broadcastViaHwWallet'),
}
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
@@ -462,6 +503,19 @@ async function finalizePsbt() {
}
}
function sendAnother() {
sendSuccess.value = null
unifiedSendAmount.value = 0
unifiedSendDest.value = ''
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
useHardwareWallet.value = false
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
@@ -526,7 +580,6 @@ async function unifiedReceive() {
params: { token: ecashReceiveToken.value.trim() },
})
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
// Ecash leaves no public ledger entry behind, so the issuer and the
// redeemed token are the only things a person can quote later if the
// payment is ever questioned. Capture them before clearing the box.
@@ -73,4 +73,36 @@ describe('Web5Identities', () => {
expect(wrapper.text()).toContain('Personal')
expect(wrapper.text()).not.toContain('Refreshing identities...')
})
it('turns a saved profile into the identity success screen with honest relay coverage', async () => {
const identity = makeIdentity('Personal')
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.update-profile') return Promise.resolve({})
if (request.method === 'identity.list') return Promise.resolve({ identities: [identity] })
if (request.method === 'identity.publish-profile') {
return Promise.resolve({
event_id: 'event-123', accepted: ['wss://one'],
rejected: [['wss://two', 'write denied']], relays_attempted: 2, published: true,
})
}
return Promise.resolve({})
})
const wrapper = mount(Web5Identities, {
props: { showStagger: false },
global: { stubs: { Teleport: true } },
})
const vm = wrapper.vm as unknown as {
openProfileEditor: (identity: ManagedIdentity) => void
publishProfile: () => Promise<void>
}
vm.openProfileEditor(identity)
await wrapper.vm.$nextTick()
await vm.publishProfile()
await flushPromises()
expect(wrapper.text()).toContain('IDENTITY UPDATED')
expect(wrapper.text()).toContain('Published to 1/2 configured relays.')
expect(wrapper.text()).toContain('wss://two rejected the event: write denied')
expect(wrapper.text()).toContain('event-123')
})
})
+12 -1
View File
@@ -39,7 +39,17 @@ export default defineConfig({
workbox: {
// /packages/ must bypass the SPA fallback — otherwise clicking the
// companion APK download link gets index.html instead of the file.
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
// The signer broker is a security-sensitive cross-window protocol,
// not an offline screen. Serving an old precached SPA shell here can
// preserve a broken session probe after the node has been updated.
navigateFallbackDenylist: [
/^\/app\//,
/^\/rpc\//,
/^\/ws/,
/^\/aiui\//,
/^\/packages\//,
/^\/nostr-signer(?:\/|$)/,
],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [
@@ -48,6 +58,7 @@ export default defineConfig({
'**/bg-*.mp4', // Exclude large background videos from precache
'**/video-intro*.mp4', // Exclude all intro video variants from precache
'**/assets/icon/**', // Icons are in includeAssets — don't duplicate in glob precache
'**/nostr-provider.js', // Always use the node's current signer protocol
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB limit
skipWaiting: false, // Wait for user to accept update
+16 -10
View File
@@ -48,7 +48,12 @@ podman_rootless() {
port_is_listening() {
local port="$1"
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$port$"
local protocol="${2:-tcp}"
case "$protocol" in
tcp) ss -ltn 2>/dev/null ;;
udp) ss -lun 2>/dev/null ;;
*) return 1 ;;
esac | awk '{print $4}' | grep -Eq "(^|:)$port$"
}
run_fix() {
@@ -564,18 +569,19 @@ fix_missing_rootless_ports() {
local fixed=false
local name
for name in $containers; do
local ports
ports=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$ports" ] || continue
local bindings
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$bindings" ] || continue
local missing=()
local port
for port in $ports; do
[ -n "$port" ] || continue
if ! port_is_listening "$port"; then
missing+=("$port")
local container_binding host_port protocol
while read -r container_binding host_port; do
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
protocol="${container_binding##*/}"
if ! port_is_listening "$host_port" "$protocol"; then
missing+=("$host_port/$protocol")
fi
done
done <<< "$bindings"
if [ ${#missing[@]} -gt 0 ]; then
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
+1 -1
View File
@@ -214,7 +214,7 @@ run_smoke_tests() {
# Test 8: Container doctor runs cleanly
TESTS=$((TESTS + 1))
local doctor_exit
ssh $SSH_OPTS "$SSH_HOST" "sudo /home/archipelago/archy/scripts/container-doctor.sh --local 2>&1 | tail -1"
ssh $SSH_OPTS "$SSH_HOST" "sudo /opt/archipelago/scripts/container-doctor.sh --local 2>&1 | tail -1"
doctor_exit=$?
if [ $doctor_exit -eq 0 ]; then
pass "container-doctor.sh: clean exit"
+38 -3
View File
@@ -18,7 +18,7 @@ from typing import Any
import yaml
SYNC_FIELDS = ("title", "version", "description", "dockerImage", "category", "tier", "icon", "repoUrl")
SYNC_FIELDS = ("title", "version", "description", "dockerImage", "category", "tier", "icon", "repoUrl", "maintainerNpub")
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
@@ -43,15 +43,20 @@ def metadata(app: dict[str, Any]) -> dict[str, Any]:
def manifest_catalog_values(app: dict[str, Any]) -> dict[str, str]:
meta = metadata(app)
container = app.get("container") if isinstance(app.get("container"), dict) else {}
build = container.get("build") if isinstance(container.get("build"), dict) else {}
values = {
"title": app.get("name"),
"version": app.get("version"),
"description": app.get("description"),
"dockerImage": container.get("image"),
# The install RPC still requires an image-shaped identifier before it
# dispatches to the manifest orchestrator. For build-source apps this
# is the local tag the orchestrator creates, not a registry image.
"dockerImage": container.get("image") or build.get("tag"),
"category": app.get("category") or meta.get("category"),
"tier": meta.get("tier"),
"icon": meta.get("icon"),
"repoUrl": meta.get("repo") or meta.get("repoUrl") or meta.get("source"),
"maintainerNpub": meta.get("maintainer_npub"),
}
return {key: str(value) for key, value in values.items() if value is not None and str(value).strip()}
@@ -98,6 +103,19 @@ def manifest_opens_in_new_tab(app: dict[str, Any]) -> bool:
return launch.get("open_in_new_tab") is True
def manifest_requires_host_frame(app: dict[str, Any]) -> bool:
"""Return whether an app must remain inside the dashboard document.
The Android companion normally promotes app sessions to its native browser
overlay. Apps that consume a parent-frame integration (for example the
NIP-07 signer bridge) must stay embedded instead.
"""
launch = metadata(app).get("launch")
if not isinstance(launch, dict):
return False
return launch.get("requires_host_frame") is True
def ts_string(value: str) -> str:
return json.dumps(value, ensure_ascii=True)
@@ -106,6 +124,7 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
ports: dict[str, int] = {}
titles: dict[str, str] = {}
new_tab_apps: list[str] = []
host_frame_apps: list[str] = []
for app_id, app in sorted(manifests.items()):
name = app.get("name")
if isinstance(name, str) and name.strip():
@@ -113,8 +132,17 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
port = manifest_launch_port(app)
if port:
ports[app_id] = port
if manifest_opens_in_new_tab(app):
opens_in_new_tab = manifest_opens_in_new_tab(app)
requires_host_frame = manifest_requires_host_frame(app)
if opens_in_new_tab and requires_host_frame:
raise ValueError(
f"{app_id}: metadata.launch.open_in_new_tab and "
"requires_host_frame cannot both be true"
)
if opens_in_new_tab:
new_tab_apps.append(app_id)
if requires_host_frame:
host_frame_apps.append(app_id)
lines = [
"/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */",
@@ -137,6 +165,13 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
])
for app_id in new_tab_apps:
lines.append(f" {ts_string(app_id)},")
lines.extend([
"])",
"",
"export const GENERATED_HOST_FRAME_APPS = new Set<string>([",
])
for app_id in host_frame_apps:
lines.append(f" {ts_string(app_id)},")
lines.extend(["])", ""])
return "\n".join(lines)
+13
View File
@@ -36,6 +36,7 @@ source "$ROOT/scripts/image-versions.sh"
set +a
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
PUBLIC_CATALOG="$ROOT/app-catalog/catalog.json" \
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
import glob
import json, os
@@ -263,6 +264,18 @@ catalog = {
"apps": dict(sorted(apps.items())),
}
# Storefront composition is release data, not node-OS UI code. Copy it from
# the public catalog into the signed registry artifact so popular ordering and
# promotions can change independently after the fleet supports this schema.
# The legacy `featured` block remains for older dashboards.
public_catalog_path = os.environ.get("PUBLIC_CATALOG")
if public_catalog_path:
with open(public_catalog_path, encoding="utf-8") as fh:
public_catalog = json.load(fh)
for key in ("featured", "storefront"):
if key in public_catalog:
catalog[key] = public_catalog[key]
with open(os.environ["OUT"], "w") as f:
json.dump(catalog, f, indent=2)
f.write("\n")
+18
View File
@@ -25,9 +25,27 @@ location /app/uptime-kuma/ {
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
# 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;
+29 -5
View File
@@ -38,6 +38,20 @@ ok() { echo -e "${GREEN}[$(date '+%H:%M:%S')] OK${NC} $*" | tee -a "$LOG_FILE";
err() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR${NC} $*" | tee -a "$LOG_FILE"; }
warn(){ echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARN${NC} $*" | tee -a "$LOG_FILE"; }
ensure_ngit_runtime() {
local installer="$REPO_DIR/image-recipe/configs/install-ngit.sh"
if [ ! -f "$installer" ]; then
warn "Pinned ngit installer missing; Nostr source cloning remains unavailable"
return 0
fi
log "Checking pinned ngit source-contribution runtime..."
if sudo sh "$installer" >>"$LOG_FILE" 2>&1; then
ok "ngit source-contribution runtime ready"
else
warn "Unable to install ngit; HTTP source access remains available"
fi
}
cleanup() {
rm -f "$LOCK_FILE"
}
@@ -142,6 +156,13 @@ for pkg in python3-venv binutils libpython3.13; do
fi
done
# Retry the pinned runtime on already-current nodes without making them rebuild
# the backend. Nodes updating from an older checkout run it again after pull,
# when the installer first becomes available.
if [ -f "$REPO_DIR/image-recipe/configs/install-ngit.sh" ]; then
ensure_ngit_runtime
fi
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
@@ -188,6 +209,8 @@ git pull origin main --ff-only 2>>"$LOG_FILE" || {
exit 1
}
ensure_ngit_runtime
NEW_VERSION=$(git rev-parse --short HEAD)
log "Now at: $NEW_VERSION"
@@ -321,17 +344,18 @@ fi
UI_DOCKER_DEST="/opt/archipelago/docker"
sudo mkdir -p "$UI_DOCKER_DEST"
UI_REBUILD_LIST=""
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
# container-specs.sh has no spec for either (and their container names break
# fips-ui, fedimint-ui, and archipelago-source are synced but NOT added to
# UI_REBUILD_LIST below:
# container-specs.sh has no spec for these apps (and their container names break
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
# orchestrator's build context for fips-ui — but BOTH read
# orchestrator's build context for fips-ui and archipelago-source — but all read
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# So source edits to these trees previously reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning a test node
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui archipelago-source; do
src="$REPO_DIR/docker/$ui"
dst="$UI_DOCKER_DEST/$ui"
[ -d "$src" ] || continue