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 @@
+
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 @@