feat(release): stage GitWorkshop and next node updates
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user