feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
@@ -0,0 +1,49 @@
import { shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
describe('NostrTabSigner visibility', () => {
beforeEach(() => {
localStorage.clear()
window.history.replaceState({}, '', '/nostr-signer')
})
afterEach(() => vi.restoreAllMocks())
function parentMessage(data: Record<string, unknown>) {
const event = new MessageEvent('message', { data, origin: window.location.origin })
Object.defineProperty(event, 'source', { value: window.parent })
window.dispatchEvent(event)
}
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
name: 'Alice',
nostr_pubkey: 'abc123',
}))
const postMessage = vi.spyOn(window.parent, 'postMessage')
const wrapper = shallowMount(NostrTabSigner)
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(true)
expect(document.body.classList.contains('nostr-signer-route')).toBe(true)
parentMessage({
type: 'archipelago:signer-init',
appId: 'archipelago-source',
appName: 'GitWorkshop',
})
postMessage.mockClear()
parentMessage({ type: 'nostr-request', id: 1, method: 'getRelays', params: {} })
await Promise.resolve()
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-show' }),
expect.anything(),
)
wrapper.unmount()
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
@@ -45,6 +45,11 @@ describe('appSessionConfig', () => {
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('does not force GitWorkshop into a dashboard iframe in Companion', () => {
expect(GENERATED_HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
expect(HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
@@ -147,4 +152,17 @@ describe('appSessionConfig', () => {
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
})
it('keeps the pre-catalog Source app on the dashboard origin', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
// Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search')
})
})
@@ -0,0 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { consentKey, hasRememberedConsent, rememberConsent } from '../nostrConsent'
describe('NIP-07 consent storage', () => {
beforeEach(() => localStorage.clear())
it('binds remembered approval to origin, app, identity and method', () => {
const key = consentKey('https://node.example', 'archipelago-source', 'identity-a', 'signEvent')
rememberConsent(key)
expect(hasRememberedConsent(key)).toBe(true)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-b', 'signEvent'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-a', 'nip44.decrypt'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://other-node.example', 'archipelago-source', 'identity-a', 'signEvent'))).toBe(false)
})
})
@@ -0,0 +1,314 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
type ProviderWindow = Window & {
__archipelagoNostr?: boolean
ArchipelagoSurface?: {
expectPageTransition: () => void
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
callback: (identity: { nostr_pubkey: string }) => void,
) => () => void
}
}
describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow
beforeEach(() => {
providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
document.documentElement.innerHTML = '<head><title>IndeedHub</title></head><body></body>'
window.history.replaceState({}, '', '/app/indeedhub/')
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
Reflect.deleteProperty(document, 'readyState')
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
})
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: userActivated },
})
window.eval(providerSource)
window.dispatchEvent(new Event('load'))
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
postMessage.mockClear()
return { frame, postMessage, signerOrigin }
}
it('reopens the chooser for a user-triggered NIP-07 login', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const publicKey = providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }),
signerOrigin,
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('uses the remembered identity for background account restoration', () => {
const { postMessage, signerOrigin } = loadProvider(false)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }),
expect.anything(),
)
})
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = {
expectPageTransition: vi.fn(),
}
providerWindow.ArchipelagoSurface = surface
const { frame, postMessage, signerOrigin } = loadProvider(false)
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
expect(frame.style.display).toBe('block')
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
expect(frame.style.display).toBe('block')
expect(frame.style.width).toBe('1px')
expect(frame.style.height).toBe('1px')
expect(frame.style.opacity).toBe('0')
expect(frame.style.pointerEvents).toBe('none')
expect(frame.style.transform).toContain('-10000px')
expect(document.querySelector('#archipelago-nostr-signer')).toBe(frame)
const publicKey = providerWindow.nostr!.getPublicKey()
const replacement = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
expect(replacement).toBe(frame)
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: replacement.contentWindow })
window.dispatchEvent(ready)
const request = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
const listener = vi.fn()
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
window.dispatchEvent(changed)
expect(listener).toHaveBeenCalledOnce()
})
it('turns an automatic IndeeHub identity into a NIP-98 signing request', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/nostr-auth/health`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
}),
signerOrigin,
)
})
it('waits for the signer success surface to hide before reloading after NIP-98', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
const signRequest = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request' && message.id != null)!
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
await Promise.resolve()
expect(raf).toHaveBeenCalledOnce()
})
it('queues a request until signer-init when the signer iframe wins the load race', () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: false },
})
Object.defineProperty(document, 'readyState', {
configurable: true,
value: 'loading',
})
window.eval(providerSource)
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalled()
window.dispatchEvent(new Event('load'))
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
})
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useAppIdentity, type SelectedIdentity } from '../useAppIdentity'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const alice: SelectedIdentity = {
id: 'alice-id',
name: 'Alice',
did: 'did:key:alice',
pubkey: 'identity-key',
nostr_pubkey: 'nostr-key',
}
describe('useAppIdentity explicit identity selection', () => {
beforeEach(() => {
localStorage.clear()
vi.mocked(rpcClient.call).mockResolvedValue({ signature: 'proof' })
})
it('reuses a stored identity normally but reopens the picker for login', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify(alice))
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(false)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.handleIdentityRequest()
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:identity', nostr_pubkey: 'nostr-key' }),
'*',
))
postMessage.mockClear()
identity.handleIdentityRequest(true)
expect(showPicker.value).toBe(true)
expect(postMessage).not.toHaveBeenCalled()
})
it('notifies the requesting app when the chooser is cancelled', () => {
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(true)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.cancelIdentitySelection()
expect(showPicker.value).toBe(false)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:identity-cancelled' }, '*')
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useNostrBridge } from '../useNostrBridge'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('useNostrBridge consent presentation', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'signed-event' })
})
afterEach(() => vi.useRealTimers())
it('keeps the contained identity loader visible through signing and completion', async () => {
const source = { postMessage: vi.fn() } as unknown as Window
const bridge = useNostrBridge(
() => ({ id: 'identity-a', name: 'Alice', nostr_pubkey: 'pubkey-a' } as never),
{
appId: () => 'archipelago-source', appName: () => 'GitWorkshop',
appUrl: () => 'https://node.test/app/archipelago-source/', frameWindow: () => source,
},
)
const event = {
data: { type: 'nostr-request', id: 'request-1', method: 'signEvent', params: { event: { kind: 1621, content: 'Fix it' } } },
source, origin: 'https://node.test',
} as MessageEvent
const handling = bridge.handleNostrRequest(event)
await Promise.resolve()
expect(bridge.showConsent.value).toBe(true)
expect(bridge.consentPhase.value).toBe('review')
bridge.approveConsent(false)
expect(bridge.consentPhase.value).toBe('signing')
expect(bridge.showConsent.value).toBe(true)
await handling
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'nostr-response', id: 'request-1' }), 'https://node.test')
await vi.advanceTimersByTimeAsync(350)
expect(bridge.consentPhase.value).toBe('success')
await vi.advanceTimersByTimeAsync(325)
expect(bridge.showConsent.value).toBe(false)
})
})
@@ -1,7 +1,12 @@
/** Static configuration maps for app session routing and display */
import { portIsGateFronted } from '../discover/curatedApps'
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import {
GENERATED_APP_PORTS,
GENERATED_APP_TITLES,
GENERATED_HOST_FRAME_APPS,
GENERATED_NEW_TAB_APPS,
} from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
@@ -50,6 +55,7 @@ export const APP_PORTS: Record<string, number> = {
/** Apps that need nginx proxy for iframe embedding.
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'archipelago-source': '/app/archipelago-source/',
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
@@ -59,6 +65,21 @@ export const PROXY_APPS: Record<string, string> = {
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
/**
* First-party apps that are deliberately being node-tested before their
* manifest reaches the release-signed catalog. Keep this list narrow: it is
* only a scheme-routing fallback, and does not make an app installable or
* trusted. Once the signed catalog carries the app, portIsGateFronted is the
* normal source of truth.
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
return portIsGateFronted(appId, port) || PRE_CATALOG_GATED_PORTS[appId] === Number(port)
}
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nostrudel': 'https://nostrudel.ninja',
@@ -81,6 +102,13 @@ export const NEW_TAB_APPS = new Set([
'tailscale',
])
/** Apps that consume an integration supplied by the dashboard parent frame.
* The Android companion normally promotes sessions into a top-level native
* WebView; doing that to one of these apps would sever its postMessage bridge. */
export const HOST_FRAME_APPS = new Set([
...GENERATED_HOST_FRAME_APPS,
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
@@ -103,6 +131,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// GitWorkshop is deliberately mounted below the dashboard origin. This is
// the only launch shape that survives every supported ingress (LAN,
// Tailscale, FIPS, Tor and reverse proxies) without assuming that a second
// high port is reachable through the same address.
if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
}
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
@@ -120,7 +158,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// would fail to connect over https at all.
try {
const port = new URL(base).port
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
if (appPortIsGateFronted(id, port)) base = matchPageScheme(base)
} catch { /* keep as-is */ }
if (routeQueryPath) base += routeQueryPath
return base
@@ -152,7 +190,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
*/
export function appOrigin(port: number, appId?: string): string {
const https = appId
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
? HTTPS_APP_IDS.has(appId) || (appPortIsGateFronted(appId, port) && pageScheme() === 'https:')
: pageScheme() === 'https:'
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
}
@@ -4,6 +4,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"adguardhome": 3030,
"aiui": 5180,
"alby-hub": 8187,
"archipelago-source": 8337,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -42,6 +43,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"adguardhome": "AdGuard Home",
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archipelago-source": "GitWorkshop",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -114,3 +116,6 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"uptime-kuma",
"vaultwarden",
])
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
])
@@ -0,0 +1,30 @@
const CONSENT_KEY = 'archipelago_nostr_consent_v2'
function readRemembered(): Set<string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(CONSENT_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [])
} catch {
return new Set()
}
}
/** Remembered NIP-07 access is scoped to the exact app, identity and method. */
export function consentKey(
origin: string,
appId: string,
identityId: string,
method: string,
): string {
return JSON.stringify(['v2', origin, appId, identityId, method])
}
export function hasRememberedConsent(key: string): boolean {
return readRemembered().has(key)
}
export function rememberConsent(key: string): void {
const remembered = readRemembered()
remembered.add(key)
try { localStorage.setItem(CONSENT_KEY, JSON.stringify([...remembered])) } catch { /* unavailable/full */ }
}
@@ -16,7 +16,7 @@ export interface SelectedIdentity {
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
return id === 'indeedhub' || id === 'nostrudel' || id === 'archipelago-source'
}
export function useAppIdentity(
@@ -68,18 +68,24 @@ export function useAppIdentity(
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
function handleIdentityRequest(force = false) {
if (IS_DEMO) return
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
if (stored && !force) sendIdentity(stored)
else showIdentityPicker.value = true
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
return {
getStoredIdentity,
sendIdentity,
onIdentitySelected,
onIframeLoadIdentity,
handleIdentityRequest,
cancelIdentitySelection,
}
}
+158 -32
View File
@@ -1,31 +1,147 @@
/** Composable for NIP-07 Nostr signing between parent and iframe apps.
*
* Replies always target event.origin — the frame's REAL origin. The app's
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
* dashboard); targeting it makes postMessage throw and the app never sees
* its response. */
/** Consent-gated NIP-07 bridge between the dashboard and an iframe app. */
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from './nostrConsent'
interface BridgeOptions {
appId: () => string
appName: () => string
appUrl: () => string
frameWindow: () => Window | null
}
export interface BridgeConsentRequest {
appName: string
method: string
identityLabel: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
const CONSENT_METHODS = new Set([
'getPublicKey', 'signEvent',
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
function senderMatches(expectedUrl: string, senderOrigin: string): boolean {
try {
const expected = new URL(expectedUrl, window.location.origin)
const sender = new URL(senderOrigin)
return expected.hostname === sender.hostname && expected.port === sender.port
} catch {
return false
}
}
export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null,
options: BridgeOptions,
) {
const consentRequest = ref<BridgeConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
function requestConsent(
method: string,
identityLabel: string,
eventKind?: number,
content?: string,
): Promise<boolean> {
return new Promise((resolve, reject) => {
consentGeneration += 1
consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
function approveConsent(remember: boolean) {
consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
consentRequest.value?.reject()
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
async function finishConsentSuccess() {
const generation = approvedGeneration
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
if (generation !== consentGeneration || !showConsent.value) return
consentPhase.value = 'success'
await new Promise(resolve => setTimeout(resolve, 325))
if (generation !== consentGeneration) return
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
}
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
}
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
if (
!source ||
source !== options.frameWindow() ||
!senderMatches(options.appUrl(), event.origin)
) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin
let prompted = false
try {
if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) {
prompted = true
const remember = await requestConsent(
method,
identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : undefined,
)
if (remember) rememberConsent(key)
}
}
let result: unknown
if (method === 'getPublicKey') {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
@@ -34,30 +150,40 @@ export function useNostrBridge(
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ 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<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
: await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip04.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else if (method === 'nip44.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip44.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else {
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,
}
}