diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 9e1b2b3f..53371229 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -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("buildRustArm64") { tasks.matching { it.name in listOf( - "mergeDebugNativeLibs", "mergeReleaseNativeLibs", - "mergeDebugJniLibFolders", "mergeReleaseJniLibFolders", + "mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs", + "mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders", ) }.configureEach { dependsOn("buildRustArm64") } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index b043486e..06f38099 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -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(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() }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 607068da..f83e2e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app-catalog/README.md b/app-catalog/README.md index de74380a..38000616 100644 --- a/app-catalog/README.md +++ b/app-catalog/README.md @@ -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 diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 463eb113..b20466b9 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -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", diff --git a/apps/PORTS.md b/apps/PORTS.md index d3fc4f0f..dc569e7c 100644 --- a/apps/PORTS.md +++ b/apps/PORTS.md @@ -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 diff --git a/apps/archipelago-source/manifest.yml b/apps/archipelago-source/manifest.yml new file mode 100644 index 00000000..8f8fd852 --- /dev/null +++ b/apps/archipelago-source/manifest.yml @@ -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 diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index c590a8ca..f5486191 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -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 diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index d7ec9171..2beb4f82 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -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###' /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 diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 91ed9875..643d351b 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index c80c3c1c..8950338f 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -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\" =>")); + } +} diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 3ab396c2..a787367a 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result { } } -pub(super) async fn patch_indeedhub_nostr_provider() { +fn patched_indeedhub_nginx_config(original: &str) -> String { + let mut conf = original + .lines() + .filter(|line| !line.contains("X-Frame-Options")) + .collect::>() + .join("\n"); + conf.push('\n'); + if !conf.contains("location = /nostr-provider.js {") { + conf = conf.replace( + "location = /sw.js {", + "location = /nostr-provider.js {\n\ + add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\ + expires off;\n\ + }\n\n\ + location = /sw.js {", + ); + } + if conf.contains("try_files") && !conf.contains("sub_filter") { + conf = conf.replacen( + "try_files $uri $uri/ /index.html;", + "try_files $uri $uri/ /index.html;\n\ + sub_filter_once on;\n\ + sub_filter '' '';", + 1, + ); + } + conf = conf.replace( + "src=\"/nostr-provider.js\"", + "src=\"/nostr-provider.js?v=tab-signer-v4\"", + ); + conf = conf.replace("tab-signer-v2", "tab-signer-v4"); + conf = conf.replace("tab-signer-v3", "tab-signer-v4"); + conf.replace( + "proxy_set_header X-Forwarded-Prefix /api;", + "proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;", + ) +} + +pub(crate) async fn patch_indeedhub_nostr_provider() { tokio::time::sleep(std::time::Duration::from_secs(5)).await; - let _ = tokio::process::Command::new("podman") - .args([ - "exec", - "indeedhub", - "sed", - "-i", - "/X-Frame-Options/d", - "/etc/nginx/conf.d/default.conf", - ]) + // Frontend assets can change during a dashboard-only OTA while the + // IndeedHub container keeps running. Reconcile the injected provider on + // daemon startup as well as app install/start, but stay quiet when the app + // is not installed or is intentionally stopped. + let running = tokio::process::Command::new("podman") + .args(["inspect", "-f", "{{.State.Running}}", "indeedhub"]) .output() - .await; - - let provider_src = "/opt/archipelago/web-ui/nostr-provider.js"; - if tokio::fs::metadata(provider_src).await.is_ok() { - let _ = tokio::process::Command::new("podman") - .args([ - "cp", - provider_src, - "indeedhub:/usr/share/nginx/html/nostr-provider.js", - ]) - .output() - .await; + .await + .map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true") + .unwrap_or(false); + if !running { + return; } - let check = tokio::process::Command::new("podman") - .args([ - "exec", - "indeedhub", - "grep", - "-q", - "nostr-provider", - "/etc/nginx/conf.d/default.conf", - ]) + // `podman exec` cannot always join a rootless container's delegated cgroup + // from the system service, while Podman 5's copier refuses to overwrite an + // existing regular file. Mount the rootless storage namespace instead; + // this replaces both files without entering the container's cgroup. + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let tmp_dir = format!("/tmp/indeedhub-nginx-patch-{}-{unique}", std::process::id()); + let tmp_path = format!("{tmp_dir}/default.conf"); + if tokio::fs::create_dir(&tmp_dir).await.is_err() { + tracing::warn!("IndeeHub signer reconciliation could not create its temporary directory"); + return; + } + + let mount_out = tokio::process::Command::new("podman") + .args(["unshare", "podman", "mount", "indeedhub"]) .output() .await; - let already_patched = check.map(|o| o.status.success()).unwrap_or(false); + let container_root = mount_out + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .filter(|path| { + std::path::Path::new(path).is_absolute() + && path.contains("/containers/storage/overlay/") + && path.ends_with("/merged") + }); + let Some(container_root) = container_root else { + let _ = tokio::fs::remove_dir(&tmp_dir).await; + tracing::warn!("IndeeHub signer reconciliation could not mount rootless storage"); + return; + }; - if !already_patched { - let cat_out = tokio::process::Command::new("podman") - .args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"]) + let provider_src = "/opt/archipelago/web-ui/nostr-provider.js"; + let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js"); + let provider_copied = tokio::fs::metadata(provider_src).await.is_ok() + && tokio::process::Command::new("podman") + .args([ + "unshare", + "install", + "-m", + "644", + provider_src, + &provider_dest, + ]) .output() - .await; + .await + .map(|out| out.status.success()) + .unwrap_or(false); - if let Ok(out) = cat_out { - if out.status.success() { - let conf = String::from_utf8_lossy(&out.stdout).to_string(); - let conf = conf.replace( - "location = /sw.js {", - "location = /nostr-provider.js {\n\ - add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\ - expires off;\n\ - }\n\n\ - location = /sw.js {", - ); - let conf = if conf.contains("try_files") && !conf.contains("sub_filter") { - conf.replacen( - "try_files $uri $uri/ /index.html;", - "try_files $uri $uri/ /index.html;\n\ - sub_filter_once on;\n\ - sub_filter '' '';", - 1, - ) - } else { - conf - }; + let copy_out = tokio::process::Command::new("podman") + .args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path]) + .output() + .await; - let tmp_path = "/tmp/indeedhub-nginx-patch.conf"; - if tokio::fs::write(tmp_path, &conf).await.is_ok() { - let _ = tokio::process::Command::new("podman") - .args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"]) + let mut config_copied = false; + if let Ok(out) = copy_out { + if out.status.success() { + if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await { + let conf = patched_indeedhub_nginx_config(&original); + if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() { + config_copied = tokio::process::Command::new("podman") + .args([ + "unshare", + "install", + "-m", + "644", + &tmp_path, + &format!("{container_root}/etc/nginx/conf.d/default.conf"), + ]) .output() - .await; - let _ = tokio::fs::remove_file(tmp_path).await; + .await + .map(|out| out.status.success()) + .unwrap_or(false); + if config_copied { + let _ = tokio::fs::remove_file(&tmp_path).await; + config_copied = tokio::process::Command::new("podman") + .args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path]) + .output() + .await + .map(|out| out.status.success()) + .unwrap_or(false) + && tokio::fs::read_to_string(&tmp_path) + .await + .map(|actual| actual == conf) + .unwrap_or(false); + } + } else if conf == original + && conf.contains("location = /nostr-provider.js {") + && conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"") + { + config_copied = true; } } } } - + let _ = tokio::fs::remove_file(&tmp_path).await; + let _ = tokio::fs::remove_dir(&tmp_dir).await; let _ = tokio::process::Command::new("podman") - .args([ - "exec", - "indeedhub", - "sed", - "-i", - "s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|", - "/etc/nginx/conf.d/default.conf", - ]) + .args(["unshare", "podman", "unmount", "indeedhub"]) .output() .await; let reload = tokio::process::Command::new("podman") - .args(["exec", "indeedhub", "nginx", "-s", "reload"]) + .args(["kill", "--signal", "HUP", "indeedhub"]) .output() .await; match reload { - Ok(o) if o.status.success() => { + Ok(o) if o.status.success() && provider_copied && config_copied => { info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded"); } Ok(o) => { tracing::warn!( - "IndeeHub nginx reload failed: {}", + "IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}", + provider_copied, + config_copied, String::from_utf8_lossy(&o.stderr) ); } @@ -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 '' '';", - 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 '' ''; + } +} +"#; + 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 [ diff --git a/core/archipelago/src/api/rpc/package/mod.rs b/core/archipelago/src/api/rpc/package/mod.rs index bb7c11bc..f6956aa7 100644 --- a/core/archipelago/src/api/rpc/package/mod.rs +++ b/core/archipelago/src/api/rpc/package/mod.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index a4ca5600..6a452212 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -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, })) } diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index f07788f9..76ba2268 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -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//` 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 { // 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 { + async fn do_login( + &self, + app: &GatedPort, + form: &Form, + client_ip: IpAddr, + mount_prefix: &str, + ) -> Response { 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, client_ip: IpAddr, + mount_prefix: &str, ) -> Response { 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, token: &str) { +async fn set_session_cookie(resp: &mut Response, 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, 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 { +async fn set_csrf_cookie(resp: &mut Response, 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 { + 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 { + 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.

"#, 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 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#"
"#, - 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 { +fn page( + title: &str, + app: &GatedPort, + body: &str, + status: StatusCode, + mount_prefix: &str, +) -> Response { let html = format!( r#" @@ -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 { +fn login_page( + app: &GatedPort, + error: Option<&str>, + status: StatusCode, + mount_prefix: &str, +) -> Response { 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#"
{}
"#, 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 { +fn totp_page( + app: &GatedPort, + error: Option<&str>, + status: StatusCode, + mount_prefix: &str, +) -> Response { let body = format!( r#"{icon}

Two-factor code

@@ -1133,9 +1290,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon err = error .map(|e| format!(r#"
{}
"#, 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#""#.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(" ++ ++ ++ + + +
+@@ -119,7 +125,7 @@ +
+ ; + } + ++/** 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 ( +- ++ ++ + + + +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" + > + GitWorkshop +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" + > +- GitWorkshop ++ GitWorkshop + + +
+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 +

+ + 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 ( + + +- Extension ++ Extension / Archipelago + + ); + 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 = ({ + 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 }; ++ } ++ ).archipelagoNostr; ++ if (archipelago?.selectIdentity) await archipelago.selectIdentity(); + await login.extension(); + onLogin(); + onClose(); +@@ -437,7 +443,9 @@ const LoginDialog: React.FC = ({ + disabled={isLoading} + > + +- {isLoading ? "Logging in..." : "Log in with Extension"} ++ {isLoading ++ ? "Logging in..." ++ : "Log in with Extension / Archipelago"} + + )} + +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 ( ++
++
++

++ ++ Nostr network ++

++ ++
++ ++ {recent === undefined || (isLoading && recent.length === 0) ? ( ++
++ {Array.from({ length: 5 }).map((_, i) => ( ++ ++ ))} ++
++ ) : recent.length > 0 ? ( ++
++ {recent.map((repo) => ( ++ ++ ))} ++
++ ) : ( ++
++

++ No network repositories available ++

++

++ Check the git index relay in Settings ++

++
++ )} ++
++ ); ++} ++ + // --------------------------------------------------------------------------- + // Embedded notifications panel (compact, inbox only, max 5) + // --------------------------------------------------------------------------- +@@ -591,6 +650,8 @@ export function Dashboard() { + + + ++ ++ +
+
+
diff --git a/docker/archipelago-source/nginx-main.conf b/docker/archipelago-source/nginx-main.conf new file mode 100644 index 00000000..1760fafb --- /dev/null +++ b/docker/archipelago-source/nginx-main.conf @@ -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; +} diff --git a/docker/archipelago-source/nginx.conf b/docker/archipelago-source/nginx.conf new file mode 100644 index 00000000..9679cb48 --- /dev/null +++ b/docker/archipelago-source/nginx.conf @@ -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; + } +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 7f7e2179..741de1bf 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -132,7 +132,7 @@ curl -s http:///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`. diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index 42e00f07..76f6b7f1 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -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 `` before - `` 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 + `` before + `` 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: diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index ed29741f..d740d07a 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -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/` 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 diff --git a/docs/nostr-git-source-hosting.md b/docs/nostr-git-source-hosting.md index 908ad300..e154f68b 100644 --- a/docs/nostr-git-source-hosting.md +++ b/docs/nostr-git-source-hosting.md @@ -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//` or `pr/`. -- 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:////archy` - - `https:////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? diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index c03c62a4..be1b67b2 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -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" diff --git a/image-recipe/configs/archipelago-doctor.service b/image-recipe/configs/archipelago-doctor.service index 6112febb..6caa33b7 100644 --- a/image-recipe/configs/archipelago-doctor.service +++ b/image-recipe/configs/archipelago-doctor.service @@ -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 diff --git a/image-recipe/configs/install-ngit.sh b/image-recipe/configs/install-ngit.sh new file mode 100755 index 00000000..54689701 --- /dev/null +++ b/image-recipe/configs/install-ngit.sh @@ -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" diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 052d932e..7fb83ff4 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -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 '' ''; } + # 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; } } - diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index 83f86cac..db8dc03e 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -33,9 +33,27 @@ location /app/uptime-kuma/ { sub_filter_once on; sub_filter '' ''; } +# 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; diff --git a/neode-ui/e2e/nostr-provider-handoff.spec.ts b/neode-ui/e2e/nostr-provider-handoff.spec.ts new file mode 100644 index 00000000..49156533 --- /dev/null +++ b/neode-ui/e2e/nostr-provider-handoff.spec.ts @@ -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: ` + + `, + }) + return + } + if (url.port === '7778') { + await route.fulfill({ + contentType: 'text/html', + body: `IndeedHub + + +
app-ready
`, + }) + 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') +}) diff --git a/neode-ui/playwright.config.ts b/neode-ui/playwright.config.ts index dc416849..39fca94c 100644 --- a/neode-ui/playwright.config.ts +++ b/neode-ui/playwright.config.ts @@ -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: [ { diff --git a/neode-ui/public/assets/img/app-icons/gitworkshop-dc36db6.svg b/neode-ui/public/assets/img/app-icons/gitworkshop-dc36db6.svg new file mode 100644 index 00000000..ae2c2d0d --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/gitworkshop-dc36db6.svg @@ -0,0 +1,20 @@ + + GitWorkshop + + + + + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/featured/archipelago-source-banner.webp b/neode-ui/public/assets/img/featured/archipelago-source-banner.webp new file mode 100644 index 00000000..2a447677 Binary files /dev/null and b/neode-ui/public/assets/img/featured/archipelago-source-banner.webp differ diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 463eb113..b20466b9 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -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", diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index fc124bd9..804d080d 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -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 = - '
' + - '' + - '' + - '' + - '' + - '
' + (message || 'Signing in...') + '
' + - '
'; - overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);'; - var style = document.createElement('style'); - style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}'; - document.head.appendChild(style); - document.body.appendChild(overlay); - } - - function updateLoader(message) { - if (!overlay) return; - var txt = overlay.querySelector('div > div'); - if (txt) txt.textContent = message; - } - - function hideLoader() { - if (overlay) { overlay.remove(); overlay = null; } - } - - // --- Direct NIP-98 Auth --- + // Optional direct NIP-98 session bootstrap for apps that use it. Signing + // itself is shown by the shared broker, so this deliberately adds no second + // full-screen loader inside the app. var authDone = false; function doNip98Auth(pubkey) { if (authDone) return; authDone = true; + var healthUrl = window.location.origin + '/api/nostr-auth/health'; + var sessionUrl = window.location.origin + '/api/auth/nostr/session'; + var healthController = new AbortController(); + var healthTimeout = setTimeout(function () { healthController.abort(); }, 3000); - var apiBase = '/api'; - var healthUrl = window.location.origin + apiBase + '/nostr-auth/health'; - var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session'; - - // 1. Check if API backend is reachable (3s timeout) - var hc = new AbortController(); - var ht = setTimeout(function () { hc.abort(); }, 3000); - - fetch(healthUrl, { signal: hc.signal }).then(function (r) { - clearTimeout(ht); - if (!r.ok) throw new Error('Health ' + r.status); - - // 2. API is up — show loader and do NIP-98 - showLoader('Signing in with Nostr...'); - var now = Math.floor(Date.now() / 1000); - var event = { - kind: 27235, created_at: now, content: '', pubkey: pubkey, - tags: [['u', sessionUrl], ['method', 'POST']] - }; - console.log('[nostr-provider] NIP-98: signing for', sessionUrl); - return window.nostr.signEvent(event); - + fetch(healthUrl, { signal: healthController.signal }).then(function (response) { + clearTimeout(healthTimeout); + if (!response.ok) throw new Error('Health ' + response.status); + return window.nostr.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + pubkey: pubkey, + tags: [['u', sessionUrl], ['method', 'POST']], + }); }).then(function (signed) { - updateLoader('Creating session...'); - var ac = new AbortController(); - setTimeout(function () { ac.abort(); }, 10000); + var controller = new AbortController(); + setTimeout(function () { controller.abort(); }, 10000); return fetch(sessionUrl, { method: 'POST', headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) }, - signal: ac.signal + signal: controller.signal, }); - - }).then(function (res) { - console.log('[nostr-provider] NIP-98: response', res.status); - if (!res.ok) throw new Error('Auth failed: ' + res.status); - return res.json(); - + }).then(function (response) { + if (!response.ok) throw new Error('Auth failed: ' + response.status); + return response.json(); }).then(function (data) { - if (data.accessToken) { - sessionStorage.setItem('nostr_token', data.accessToken); - sessionStorage.setItem('nostr_pubkey', pubkey); - if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken); - updateLoader('Signed in! Loading...'); - console.log('[nostr-provider] NIP-98: success, reloading...'); - setTimeout(function () { window.location.reload(); }, 400); - } else { - hideLoader(); authDone = false; - } - - }).catch(function (err) { - hideLoader(); authDone = false; - var msg = err.message || String(err); - if (msg.indexOf('abort') > -1) msg = 'API timeout'; - console.warn('[nostr-provider] NIP-98 skipped:', msg); + if (!data.accessToken) throw new Error('Authentication returned no access token'); + sessionStorage.setItem('nostr_token', data.accessToken); + sessionStorage.setItem('nostr_pubkey', pubkey); + if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken); + return waitForSignerToHide().then(function () { + // Give WebView one paint after the iframe is hidden before replacing + // the document. The stored session is already durable at this point. + return new Promise(function (resolve) { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(resolve); + }); + }); + }).then(function () { + if (window.ArchipelagoSurface && + typeof window.ArchipelagoSurface.expectPageTransition === 'function') { + window.ArchipelagoSurface.expectPageTransition(); + } + window.location.reload(); + }); + }).catch(function (error) { + authDone = false; + var message = error && error.message ? error.message : String(error); + if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout'; + console.warn('[nostr-provider] NIP-98 skipped:', message); }); } - // Listen for identity from parent Archipelago frame window.addEventListener('message', function (e) { - if (!e.data || e.data.type !== 'archipelago:identity') return; - var pk = e.data.nostr_pubkey; - console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none'); - if (!pk) return; - - // Skip if already signed in with a real token (not mock) + if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return; + if (e.source !== window && e.source !== window.parent) return; + var pubkey = e.data.nostr_pubkey; + if (!pubkey) return; try { var token = sessionStorage.getItem('nostr_token'); - if (token && token.indexOf('mock-') === -1) { - console.log('[nostr-provider] Already signed in with real token'); - return; - } - } catch (x) {} - - setTimeout(function () { doNip98Auth(pk); }, 1500); + if (token && token.indexOf('mock-') === -1) return; + } catch (_) {} + setTimeout(function () { doNip98Auth(pubkey); }, 1500); }); + + // Only identity-aware apps open the chooser eagerly. The provider is also + // injected into several ordinary app proxies; those stay untouched unless + // they actually invoke a NIP-07 method, which lazily creates the broker. + if (!embedded && ['indeedhub', 'nostrudel', 'archipelago-source'].indexOf(inferAppId()) !== -1) { + createSignerFrame(); + } + + // The provider is injected in , 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 }); + } })(); diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index d9e67039..7e7e9c77 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -1,5 +1,7 @@ diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 236d3b76..a0ac81a4 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -118,7 +118,22 @@ -
+
+
+ +
+
+ + + +
@@ -162,30 +177,31 @@
+ + +
- - - - - diff --git a/neode-ui/src/components/NostrIdentityOrb.vue b/neode-ui/src/components/NostrIdentityOrb.vue new file mode 100644 index 00000000..d19a6e50 --- /dev/null +++ b/neode-ui/src/components/NostrIdentityOrb.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/neode-ui/src/components/NostrIdentityPicker.vue b/neode-ui/src/components/NostrIdentityPicker.vue index 2f28d470..ea19bc7a 100644 --- a/neode-ui/src/components/NostrIdentityPicker.vue +++ b/neode-ui/src/components/NostrIdentityPicker.vue @@ -1,13 +1,10 @@ diff --git a/neode-ui/src/components/NostrSignConsent.vue b/neode-ui/src/components/NostrSignConsent.vue index c112ea7d..f604efcd 100644 --- a/neode-ui/src/components/NostrSignConsent.vue +++ b/neode-ui/src/components/NostrSignConsent.vue @@ -1,152 +1,93 @@ diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 65f6a2ba..8a43abc2 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -1,5 +1,5 @@ + + diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index fdab5925..c0f3543e 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -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')) diff --git a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts index 531a2e57..15352bd6 100644 --- a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts +++ b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts @@ -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) diff --git a/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts b/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts new file mode 100644 index 00000000..fbab01ae --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts @@ -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) { + 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) + }) +}) diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index 0ef59457..a6c0ce19 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -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') + }) }) diff --git a/neode-ui/src/views/appSession/__tests__/nostrConsent.test.ts b/neode-ui/src/views/appSession/__tests__/nostrConsent.test.ts new file mode 100644 index 00000000..a01372bd --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/nostrConsent.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts new file mode 100644 index 00000000..fbd4f818 --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts @@ -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 } + archipelagoNostr?: { + selectIdentity: () => Promise + 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 = 'IndeedHub' + 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('#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('#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('#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']) + }) +}) diff --git a/neode-ui/src/views/appSession/__tests__/useAppIdentity.test.ts b/neode-ui/src/views/appSession/__tests__/useAppIdentity.test.ts new file mode 100644 index 00000000..717f31af --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/useAppIdentity.test.ts @@ -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' }, '*') + }) +}) diff --git a/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts b/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts new file mode 100644 index 00000000..366c2cb4 --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index bc4c7a2a..606e3a9b 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -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 = { /** 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 = { + '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 = { export const HTTPS_PROXY_PATHS: Record = { } +/** + * 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 = { + '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 = { '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([]) @@ -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}` } diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 4d555b91..0d4c7b67 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -4,6 +4,7 @@ export const GENERATED_APP_PORTS: Record = { "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 = { "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([ "uptime-kuma", "vaultwarden", ]) + +export const GENERATED_HOST_FRAME_APPS = new Set([ +]) diff --git a/neode-ui/src/views/appSession/nostrConsent.ts b/neode-ui/src/views/appSession/nostrConsent.ts new file mode 100644 index 00000000..27cd586c --- /dev/null +++ b/neode-ui/src/views/appSession/nostrConsent.ts @@ -0,0 +1,30 @@ +const CONSENT_KEY = 'archipelago_nostr_consent_v2' + +function readRemembered(): Set { + 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 */ } +} diff --git a/neode-ui/src/views/appSession/useAppIdentity.ts b/neode-ui/src/views/appSession/useAppIdentity.ts index aa92f7a7..a906488a 100644 --- a/neode-ui/src/views/appSession/useAppIdentity.ts +++ b/neode-ui/src/views/appSession/useAppIdentity.ts @@ -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, } } diff --git a/neode-ui/src/views/appSession/useNostrBridge.ts b/neode-ui/src/views/appSession/useNostrBridge.ts index 042d8610..a2187134 100644 --- a/neode-ui/src/views/appSession/useNostrBridge.ts +++ b/neode-ui/src/views/appSession/useNostrBridge.ts @@ -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(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 { + 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({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } }) - } else { - result = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) - } - 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 || '*') + result = identityId + ? await rpcClient.call({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } }) + : await rpcClient.call({ 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 { + throw new Error(`Unsupported NIP-07 method: ${method}`) + } + 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, + } } diff --git a/neode-ui/src/views/apps/AppIconGrid.vue b/neode-ui/src/views/apps/AppIconGrid.vue index d7baafaf..9155a0b2 100644 --- a/neode-ui/src/views/apps/AppIconGrid.vue +++ b/neode-ui/src/views/apps/AppIconGrid.vue @@ -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 { @@ -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) { diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 44b4cd4f..1add85cd 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -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 } @@ -171,6 +194,8 @@ export async function fetchAppCatalog(): Promise { // 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 { 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 { 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' }, ] } diff --git a/neode-ui/src/views/discover/types.ts b/neode-ui/src/views/discover/types.ts index 22856944..40ab28bb 100644 --- a/neode-ui/src/views/discover/types.ts +++ b/neode-ui/src/views/discover/types.ts @@ -18,6 +18,7 @@ export type MarketplaceApp = Partial & { containerConfig?: ContainerConfig requires?: string[] tier?: string + maintainerNpub?: string } export type FeaturedApp = MarketplaceApp & { diff --git a/neode-ui/src/views/marketplace/marketplaceData.ts b/neode-ui/src/views/marketplace/marketplaceData.ts index be4ae0b7..fe30a0ab 100644 --- a/neode-ui/src/views/marketplace/marketplaceData.ts +++ b/neode-ui/src/views/marketplace/marketplaceData.ts @@ -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', + }, ] } diff --git a/neode-ui/src/views/settings/NodeCertificateSection.vue b/neode-ui/src/views/settings/NodeCertificateSection.vue index 2cf8a490..0532fd52 100644 --- a/neode-ui/src/views/settings/NodeCertificateSection.vue +++ b/neode-ui/src/views/settings/NodeCertificateSection.vue @@ -1,5 +1,6 @@ @@ -109,6 +119,7 @@ onMounted(probe)
@@ -132,29 +143,85 @@ onMounted(probe) How to install it -
-

macOS — open the file, add it to the - login keychain, then find it in Keychain Access, open it, expand Trust - and set “When using this certificate” to Always Trust.

-

iOS / iPadOS — download it in Safari and - allow the profile, then Settings → General → VPN & Device Management to - install it, and finally Settings → General → About → Certificate Trust Settings - to switch it on. Both steps are required.

-

Windows — right-click → Install - Certificate → Local Machine → place it in Trusted Root Certification - Authorities.

-

Android — Settings → Security → - Encryption & credentials → Install a certificate → CA certificate.

-

Linux — copy to - /usr/local/share/ca-certificates/ - and run sudo update-ca-certificates. - Firefox keeps its own store — add it under Settings → Privacy & Security → - View Certificates → Authorities.

-

+

+

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.

+ +
+

macOS

+
    +
  1. Double-click the file to add it to your login keychain.
  2. +
  3. Open Keychain Access and find it under Certificates.
  4. +
  5. Open it, expand Trust, set “When using this certificate” to Always Trust, then close the window and enter your password.
  6. +
+

Quit and reopen your browser after changing the trust setting.

+
+ +
+

iOS / iPadOS

+
    +
  1. Open the file in Safari and tap Allow to download the profile.
  2. +
  3. Settings → Profile Downloaded, or General → VPN & Device Management → Install.
  4. +
  5. Settings → General → About → Certificate Trust Settings → switch the certificate on.
  6. +
+

The final Certificate Trust Settings step is required.

+
+ +
+

Windows

+
    +
  1. Right-click the file and choose Install Certificate.
  2. +
  3. Select Local Machine.
  4. +
  5. Choose “Place all certificates in the following store” → Trusted Root Certification Authorities → Finish.
  6. +
+
+ +
+

Android

+

Settings → Security → Encryption & credentials → Install a certificate → CA certificate, then choose the file.

+

Browsers using the system certificate store will trust it after restart. Apps that pin their own certificates may still refuse it.

+
+ +
+

Linux

+
sudo install -m644 /path/to/node-ca.crt /usr/local/share/ca-certificates/node-ca.crt && sudo update-ca-certificates
+

Firefox: Settings → Privacy & Security → View Certificates → Authorities → Import, then enable “Trust this CA to identify websites”.

+

Arch / Manjaro:

+
sudo cp node-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo update-ca-trust extract
+
+ +
+

Restart the browser first

+

Chrome, Brave, Firefox, and Safari cache certificate decisions. Fully quit and reopen the browser before troubleshooting a certificate that still appears untrusted.

+

For a one-visit sanity check on a machine you own, Chrome and Brave accept the keyboard shortcut thisisunsafe on the certificate error page. Use this only for testing.

+
+ +
+

If the node name does not resolve

+

Certificate trust and DNS are separate. If node.local does not resolve, prefer the node's Tailscale MagicDNS name when available.

+

To keep using a local name on Linux or macOS, add the node address to /etc/hosts:

+
echo '192.168.x.y  mynode.local' | sudo tee -a /etc/hosts
+

On Linux, if that still fails, inspect grep '^hosts:' /etc/nsswitch.conf. Put files before mdns_minimal [NOTFOUND=return] so an mDNS miss cannot block /etc/hosts.

+
+ +
+

Symptoms

+
+ + + + + + + + + +
What you seeLikely cause
Not trusted / ERR_CERT_AUTHORITY_INVALIDThe certificate is not installed, or the browser was not restarted.
This site can't be reached / DNS errorName resolution, not TLS. Check the DNS guidance above.
curl works, browser does notA separate browser certificate store or a stale browser process.
+
+
diff --git a/neode-ui/src/views/settings/__tests__/NodeCertificateSection.test.ts b/neode-ui/src/views/settings/__tests__/NodeCertificateSection.test.ts new file mode 100644 index 00000000..9d4f706e --- /dev/null +++ b/neode-ui/src/views/settings/__tests__/NodeCertificateSection.test.ts @@ -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') + }) +}) diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue index 79f60ec9..d953caad 100644 --- a/neode-ui/src/views/web5/Web5Identities.vue +++ b/neode-ui/src/views/web5/Web5Identities.vue @@ -392,6 +392,17 @@
@@ -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(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 }, - }) - 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 }, + params: { id: identity.id, ...profileForm.value }, }) 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` + 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: 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 })