Unify legacy overlay signing with the queued iframe consent bridge
This commit is contained in:
@@ -635,3 +635,44 @@ Still track the separate legacy `stores/appLauncher.ts` signing handler, which
|
||||
has its own consent implementation; this deployment qualifies the AppSession /
|
||||
shared bridge path. Do not describe every possible app launcher or the physical
|
||||
companion grey-screen report as fully accepted from these checks.
|
||||
|
||||
## IndeeHub durable publication and relay delivery checkpoint
|
||||
|
||||
IndeeHub follow-up commits `c7cf672` and `46f128c` add a shared signed-offer
|
||||
validator, authorized publication/database outbox transaction and bounded relay
|
||||
worker. **53 backend tests pass**, including real disposable PostgreSQL and
|
||||
WebSocket integration: concurrent publishers/workers, ownership/moderation and
|
||||
registered-term checks, transaction rollback, lease recovery, lost relay ACK,
|
||||
unchanged signed-event retry and exact acceptance. Backend build passes; **74
|
||||
frontend tests** passed after sharing the protocol validator. The disposable DB,
|
||||
volume and private credentials were removed. No public announcements or wallet
|
||||
payments were made. See the IndeeHub implementation document for exact logs.
|
||||
|
||||
This is not a deployed IndeeHub source or a finished rental flow. Production
|
||||
migration/scheduling, trusted node-media registration, authenticated Backstage
|
||||
integration and settlement-backed FIPS playback remain open. Source inspection
|
||||
also confirms the existing peer ecash path only creates its durable buyer record
|
||||
after response headers: lost headers or interruption during minting can leave no
|
||||
purchase record. It additionally falls back from Cashu to Fedimint after any
|
||||
Cashu error. Complete purchase-intent/wallet-operation recovery and unambiguous
|
||||
backend selection are required before reusing that path for rentals. Existing
|
||||
successful two-node paid-file tests exercised cached delivery recovery, not that
|
||||
initial mint/response-loss gap. No new paid test was performed here.
|
||||
|
||||
## Legacy overlay signer candidate
|
||||
|
||||
The legacy overlay now reuses `useNostrBridge` rather than maintaining another
|
||||
single-promise consent implementation. The actual iframe window is registered
|
||||
by the overlay; another same-origin window cannot drive the signer. Existing
|
||||
URL-keyed identity selections and remembered consent remain compatible. Closing,
|
||||
changing URL, replacing the iframe or disposing the store cancels pending work.
|
||||
The approved completion animation remains unchanged.
|
||||
|
||||
56 focused signer/launcher tests and typecheck pass. The full dashboard suite
|
||||
passes **1,293 tests across 161 files**, with production build passing. Logs:
|
||||
`/tmp/archy-legacy-signer-tests.log`,
|
||||
`/tmp/archy-legacy-signer-full-ui-tests.log`,
|
||||
`/tmp/archy-legacy-signer-typecheck.log`,
|
||||
`/tmp/archy-legacy-signer-production-build.log`.
|
||||
The served-browser harness now covers both AppSession and the legacy overlay at
|
||||
390/1440px. Deployment/live results will be recorded separately below.
|
||||
|
||||
@@ -229,6 +229,7 @@ interface PaymentRequest {
|
||||
const store = useAppLauncherStore()
|
||||
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
watch(iframeRef, frame => store.setNostrFrame(frame?.contentWindow || null), { flush: 'post' })
|
||||
const iframeRefreshKey = ref(0)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
@@ -682,6 +683,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.setNostrFrame(null)
|
||||
clearTimers()
|
||||
stopProgress()
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
// The signed catalog's embedded manifests decide which ports the app gate
|
||||
// fronts (TLS on the same port) — prime the same shape the live catalog
|
||||
@@ -36,6 +37,90 @@ vi.stubGlobal('open', mockWindowOpen)
|
||||
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
|
||||
import { useAppStore } from '../app'
|
||||
|
||||
describe('legacy overlay native signer lifecycle', () => {
|
||||
let store: ReturnType<typeof useAppLauncherStore>
|
||||
const appUrl = 'http://192.0.2.10:19999'
|
||||
const identityKey = 'archipelago_app_identity_' + appUrl.replace(/[^a-z0-9]/gi, '_')
|
||||
const frame = { postMessage: vi.fn() } as unknown as Window
|
||||
function request(id: string, source = frame, origin = appUrl) {
|
||||
const event = new MessageEvent('message', { origin, data: { type: 'nostr-request', id,
|
||||
method: 'signEvent', params: { event: { kind: 27235, content: id } } } })
|
||||
Object.defineProperty(event, 'source', { value: source })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mockRpcCall.mockResolvedValue({ id: 'signed-fixture' })
|
||||
Object.defineProperty(window, 'location', { value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' }, configurable: true })
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true })
|
||||
localStorage.setItem(identityKey, JSON.stringify({ id: 'legacy-identity', name: 'Saved creator' }))
|
||||
store = useAppLauncherStore()
|
||||
store.open({ url: appUrl, title: 'Custom film app' })
|
||||
store.setNostrFrame(frame)
|
||||
})
|
||||
afterEach(async () => {
|
||||
store.close(); store.$dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('queues simultaneous requests and preserves the saved identity and success animation', async () => {
|
||||
request('first'); request('second')
|
||||
expect(store.consentRequest?.content).toBe('first')
|
||||
expect(store.consentRequest?.identityLabel).toBe('Saved creator')
|
||||
store.approveConsent(false)
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
expect(store.consentPhase).toBe('success')
|
||||
expect(mockRpcCall).toHaveBeenCalledTimes(1)
|
||||
expect(mockRpcCall).toHaveBeenCalledWith({ method: 'identity.nostr-sign', params: {
|
||||
id: 'legacy-identity', event: { kind: 27235, content: 'first' } } })
|
||||
await vi.advanceTimersByTimeAsync(325)
|
||||
expect(store.consentRequest?.content).toBe('second')
|
||||
store.approveConsent(false)
|
||||
await vi.advanceTimersByTimeAsync(675)
|
||||
expect(frame.postMessage).toHaveBeenCalledTimes(2)
|
||||
expect(frame.postMessage).toHaveBeenNthCalledWith(1, { type: 'nostr-response', id: 'first', result: { id: 'signed-fixture' } }, appUrl)
|
||||
expect(frame.postMessage).toHaveBeenNthCalledWith(2, { type: 'nostr-response', id: 'second', result: { id: 'signed-fixture' } }, appUrl)
|
||||
expect(store.showConsent).toBe(false)
|
||||
})
|
||||
it('rejects a same-origin window that is not the actual app iframe', async () => {
|
||||
const other = { postMessage: vi.fn() } as unknown as Window
|
||||
request('forged-window', other)
|
||||
await nextTick()
|
||||
expect(store.showConsent).toBe(false)
|
||||
expect(mockRpcCall).not.toHaveBeenCalled()
|
||||
expect(other.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['close', 'frame', 'url', 'dispose'])('settles all pending requests on %s without signing', async action => {
|
||||
request('first'); request('second')
|
||||
if (action === 'close') store.close()
|
||||
if (action === 'frame') store.setNostrFrame(null)
|
||||
if (action === 'url') store.url = 'http://192.0.2.10:19998'
|
||||
if (action === 'dispose') store.$dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockRpcCall).not.toHaveBeenCalled()
|
||||
expect(frame.postMessage).toHaveBeenCalledTimes(2)
|
||||
for (const [response] of vi.mocked(frame.postMessage).mock.calls) expect(response).toHaveProperty('error')
|
||||
})
|
||||
it('rejects identity changes before approval and can reopen after closing', async () => {
|
||||
request('old-identity')
|
||||
localStorage.setItem(identityKey, JSON.stringify({ id: 'another-identity', name: 'Other' }))
|
||||
store.approveConsent(false)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockRpcCall).not.toHaveBeenCalled()
|
||||
store.close()
|
||||
store.open({ url: appUrl, title: 'Custom film app' })
|
||||
store.setNostrFrame(frame)
|
||||
request('reopened')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
store.approveConsent(false)
|
||||
await vi.advanceTimersByTimeAsync(675)
|
||||
expect(mockRpcCall).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ id: 'another-identity' }) }))
|
||||
})
|
||||
});
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, onScopeDispose } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
@@ -13,11 +13,8 @@ import { useToast } from '@/composables/useToast'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
|
||||
import { resolveAppCredentials } from '@/views/apps/appCredentials'
|
||||
import {
|
||||
consentKey,
|
||||
hasRememberedConsent,
|
||||
rememberConsent,
|
||||
} from '@/views/appSession/nostrConsent'
|
||||
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
|
||||
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
@@ -190,16 +187,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
export interface NostrConsentRequest {
|
||||
appName: string
|
||||
method: string
|
||||
eventKind?: number
|
||||
content?: string
|
||||
identityLabel?: string
|
||||
resolve: (remember: boolean) => void
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
/** App identity (catalog icon + display name) for the companion's native
|
||||
* branded loader. Undefined when the app isn't in package-data. */
|
||||
function launchMeta(appId: string): InAppLaunchMeta | undefined {
|
||||
@@ -215,10 +202,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
const title = ref('')
|
||||
const consentRequest = ref<NostrConsentRequest | null>(null)
|
||||
const showConsent = ref(false)
|
||||
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
|
||||
const consentError = ref('')
|
||||
const credentialPrompt = ref({
|
||||
show: false,
|
||||
loading: false,
|
||||
@@ -230,9 +213,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
})
|
||||
let pendingCredentialLaunch: { appId: string; path?: string } | null = null
|
||||
let credentialGeneration = 0
|
||||
let consentApprovedAt = 0
|
||||
let consentGeneration = 0
|
||||
let approvedGeneration = 0
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Active app in the store-driven session (no route change) */
|
||||
@@ -526,7 +506,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (showConsent.value) denyConsent()
|
||||
bridge.cancelPending()
|
||||
const toRestore = previousActiveElement
|
||||
previousActiveElement = null
|
||||
isOpen.value = false
|
||||
@@ -542,176 +522,37 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function approveConsent(remember: boolean) {
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.resolve(remember)
|
||||
}
|
||||
consentApprovedAt = Date.now()
|
||||
approvedGeneration = consentGeneration
|
||||
consentPhase.value = 'signing'
|
||||
}
|
||||
|
||||
function denyConsent() {
|
||||
consentGeneration += 1
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.reject()
|
||||
consentRequest.value = null
|
||||
}
|
||||
showConsent.value = false
|
||||
consentPhase.value = 'review'
|
||||
consentError.value = ''
|
||||
}
|
||||
|
||||
async function finishConsentSuccess() {
|
||||
const generation = approvedGeneration
|
||||
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
|
||||
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
|
||||
if (generation !== consentGeneration || !showConsent.value) return
|
||||
consentPhase.value = 'success'
|
||||
await new Promise(resolve => setTimeout(resolve, 325))
|
||||
if (generation !== consentGeneration) return
|
||||
consentRequest.value = null
|
||||
showConsent.value = false
|
||||
consentPhase.value = 'review'
|
||||
}
|
||||
|
||||
function finishConsentError(error: unknown) {
|
||||
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
|
||||
consentPhase.value = 'error'
|
||||
}
|
||||
|
||||
function requestConsent(appName: string, method: string, eventKind?: number, content?: string, identityLabel?: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
consentGeneration += 1
|
||||
consentRequest.value = {
|
||||
appName, method, eventKind, content, identityLabel,
|
||||
resolve, reject,
|
||||
}
|
||||
consentPhase.value = 'review'
|
||||
consentError.value = ''
|
||||
showConsent.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
|
||||
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
|
||||
|
||||
// Only the app we actually opened may drive this bridge — see
|
||||
// senderMatchesApp for why the scheme is deliberately not compared.
|
||||
if (!senderMatchesApp(url.value, event.origin)) return
|
||||
|
||||
const origin = event.origin
|
||||
let prompted = false
|
||||
const activeAppId = resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app'
|
||||
|
||||
// Check if app has a per-app identity stored (from identity picker)
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
|
||||
let appIdentityId: string | null = null
|
||||
// Reuse the same bounded queue and consent lifecycle as AppSession. Keep
|
||||
// the legacy URL-based identity storage key so existing selections survive.
|
||||
let nostrFrame: Window | null = null
|
||||
function overlayIdentity(): SelectedIdentity | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(appKey)
|
||||
if (stored) {
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
|
||||
const idVal = (parsed as Record<string, unknown>).id
|
||||
appIdentityId = typeof idVal === 'string' ? idVal : null
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Every identity-sensitive method needs consent (or a remembered
|
||||
// approval for this origin) — not just signEvent. getPublicKey
|
||||
// deanonymizes; the decrypts turn the node into a decryption oracle.
|
||||
const CONSENT_METHODS = new Set([
|
||||
'getPublicKey', 'signEvent',
|
||||
'nip04.encrypt', 'nip04.decrypt',
|
||||
'nip44.encrypt', 'nip44.decrypt',
|
||||
])
|
||||
const scopedKey = consentKey(origin, activeAppId, appIdentityId || 'node-default', method)
|
||||
const alreadyApproved = hasRememberedConsent(scopedKey)
|
||||
if (CONSENT_METHODS.has(method) && !alreadyApproved) {
|
||||
prompted = true
|
||||
const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined
|
||||
const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined
|
||||
try {
|
||||
const remember = await requestConsent(
|
||||
title.value || 'App', method, eventKind, content,
|
||||
appIdentityId || 'Node default identity',
|
||||
)
|
||||
if (remember) rememberConsent(scopedKey)
|
||||
} catch {
|
||||
source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
|
||||
if (method === 'getPublicKey') {
|
||||
if (appIdentityId) {
|
||||
// Use the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
|
||||
method: 'identity.get', params: { id: appIdentityId }
|
||||
})
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
if (appIdentityId) {
|
||||
// Sign with the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<unknown>({
|
||||
method: 'identity.nostr-sign',
|
||||
params: { id: appIdentityId, event: params.event }
|
||||
})
|
||||
result = res
|
||||
} else {
|
||||
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
result = res
|
||||
}
|
||||
} else if (method === 'getRelays') {
|
||||
result = {}
|
||||
} else if (method === 'nip04.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip04.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else if (method === 'nip44.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip44.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else {
|
||||
throw new Error(`Unsupported NIP-07 method: ${method}`)
|
||||
}
|
||||
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
|
||||
if (prompted) void finishConsentSuccess()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
|
||||
if (prompted && showConsent.value) finishConsentError(err)
|
||||
}
|
||||
const key = 'archipelago_app_identity_' + url.value.replace(/[^a-z0-9]/gi, '_')
|
||||
const stored = JSON.parse(localStorage.getItem(key) || 'null')
|
||||
if (!stored || typeof stored.id !== 'string' || !stored.id) return null
|
||||
return { ...stored, name: typeof stored.name === 'string' ? stored.name : stored.id }
|
||||
} catch { return null }
|
||||
}
|
||||
const bridge = useNostrBridge(overlayIdentity, {
|
||||
appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app',
|
||||
appName: () => title.value || 'App',
|
||||
appUrl: () => url.value,
|
||||
frameWindow: () => isOpen.value ? nostrFrame : null,
|
||||
})
|
||||
const { handleNostrRequest, showConsent, consentRequest, consentPhase,
|
||||
consentError, approveConsent, denyConsent } = bridge
|
||||
|
||||
function setNostrFrame(frame: Window | null) {
|
||||
if (frame === nostrFrame) return
|
||||
bridge.cancelPending()
|
||||
nostrFrame = frame
|
||||
}
|
||||
watch(url, () => bridge.cancelPending(), { flush: 'sync' })
|
||||
onScopeDispose(() => {
|
||||
window.removeEventListener('message', handleNostrRequest)
|
||||
bridge.dispose()
|
||||
nostrFrame = null
|
||||
})
|
||||
|
||||
// Listen for NIP-07 requests only while an app is open
|
||||
watch(isOpen, (open) => {
|
||||
@@ -719,8 +560,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
window.addEventListener('message', handleNostrRequest)
|
||||
} else {
|
||||
window.removeEventListener('message', handleNostrRequest)
|
||||
bridge.cancelPending()
|
||||
}
|
||||
})
|
||||
}, { flush: 'sync' })
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
@@ -743,5 +585,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
consentError,
|
||||
approveConsent,
|
||||
denyConsent,
|
||||
setNostrFrame,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,15 +12,20 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
|
||||
if(!privateHost || !['http:','https:'].includes(url.protocol) || url.username || url.password)throw new Error('Refusing to send qualification cookies outside a private node');
|
||||
const cookies=JSON.parse(fs.readFileSync(cookieFile,'utf8'));
|
||||
const browser=await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
|
||||
for(const width of [390,1440]){
|
||||
for(const mode of ['session','overlay']) for(const width of [390,1440]){
|
||||
const fixturePort=mode==='overlay'?19999:7778;
|
||||
const fixtureUrl=new URL(origin); fixtureUrl.port=String(fixturePort); fixtureUrl.pathname='/';
|
||||
const overlayUrl=fixtureUrl.href;
|
||||
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
|
||||
try{
|
||||
await context.addCookies(Object.entries(cookies).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'})));
|
||||
await context.addInitScript(()=>{
|
||||
await context.addInitScript(({overlayUrl})=>{
|
||||
localStorage.setItem('neode-auth','true');
|
||||
localStorage.removeItem('archipelago_nostr_consent_v2');
|
||||
localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify({id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)}));
|
||||
});
|
||||
const identity={id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)};
|
||||
localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify(identity));
|
||||
localStorage.setItem('archipelago_app_identity_'+overlayUrl.replace(/[^a-z0-9]/gi,'_'),JSON.stringify(identity));
|
||||
},{overlayUrl});
|
||||
let signed=0, frameLoads=0; const signedContents=[];
|
||||
await context.route('**/rpc/v1',async route=>{
|
||||
let request;try{request=route.request().postDataJSON()}catch{return route.continue()}
|
||||
@@ -34,7 +39,7 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await context.route('**:7778/**',async route=>{
|
||||
await context.route('**:'+fixturePort+'/**',async route=>{
|
||||
if(route.request().resourceType()!=='document')return route.abort();
|
||||
frameLoads++;
|
||||
return route.fulfill({contentType:'text/html',body:`<!doctype html><html><body><p id="result">waiting</p><script>
|
||||
@@ -44,10 +49,14 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
|
||||
</script></body></html>`});
|
||||
});
|
||||
const page=await context.newPage();
|
||||
await page.goto(origin+'/dashboard/app-session/indeedhub',{waitUntil:'domcontentloaded'});
|
||||
await page.goto(origin+(mode==='overlay'?'/dashboard/apps':'/dashboard/app-session/indeedhub'),{waitUntil:'domcontentloaded'});
|
||||
if(mode==='overlay'){
|
||||
await page.waitForFunction(()=>document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'));
|
||||
await page.evaluate(url=>document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').open({url,title:'Qualification custom app'}),overlayUrl);
|
||||
}
|
||||
const approve=page.getByRole('button',{name:'Approve',exact:true});
|
||||
try { await expect(approve).toBeVisible({timeout:30000}); } catch(error) {
|
||||
console.log(JSON.stringify({node,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))}));
|
||||
console.log(JSON.stringify({node,mode,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))}));
|
||||
throw error;
|
||||
}
|
||||
await approve.click();
|
||||
@@ -55,14 +64,14 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
|
||||
await expect(approve).toBeVisible({timeout:10000});
|
||||
await approve.click();
|
||||
await expect.poll(()=>signed,{timeout:10000}).toBe(2);
|
||||
const frame=page.frameLocator('iframe[src*="7778"]');
|
||||
const frame=page.frameLocator('iframe[src*="'+fixturePort+'"]');
|
||||
await expect(frame.locator('#result')).toContainText('"id":"first","ok":true');
|
||||
try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,width,frameLoads,signedContents})); throw error; }
|
||||
try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,mode,width,frameLoads,signedContents})); throw error; }
|
||||
await page.waitForTimeout(800); // allow the required 675ms completion presentation to finish
|
||||
await expect(approve).toHaveCount(0);
|
||||
expect(frameLoads).toBe(1);
|
||||
expect(signedContents).toEqual(['qualification-first','qualification-second']);
|
||||
console.log(JSON.stringify({node,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'}));
|
||||
console.log(JSON.stringify({node,mode,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'}));
|
||||
}finally{await context.close()}
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user