Keep slow app sessions alive and defer automatic companion prompts

This commit is contained in:
archipelago
2026-10-06 12:55:47 -04:00
parent a3f0bf0af7
commit a49d4d7128
10 changed files with 367 additions and 19 deletions
+33
View File
@@ -708,3 +708,36 @@ RPC tests and the complete isolated suite (**1,749 pass, zero fail, five existin
ignores**) pass. No real payment or live wallet mutation was used. This is not yet
in the running backend. Durable pre-mint purchase journaling and seller receipt
recovery remain open in [the recovery follow-up](paid-content-recovery-followup.md).
## Payment selection deployed; slow-app launch regression under qualification
The production backend at `a3f0bf0a` built successfully and is deployed on dev
and Yaya. SHA256 `2c881f68592f7a395cc53c641cc936217426893a7f9f994253026ce36cda79f3`.
Both nodes pass health and authenticated RPC; persistent session keys, UI and
all app container identities/start times remained unchanged. Rollback paths are
recorded in the private artifact receipt and `/tmp/archy-payment-selection-{dev,yaya}-deploy.log`.
No payment was made for this deployment. This closes backend-selection rollout,
not the durable initial-payment recovery gate.
Actual Yaya mobile-width IndeeHub native login returned session201 and profile200,
and retained login after reload (`/tmp/archy-indeehub-live-login.log`). A broader
attempt then failed on dev/Yaya: the 12-second AppSession load timeout destroys
the iframe, and the automatic companion introduction can cover login. These
failed runs are retained, not counted as acceptance. Source now preserves a
slow iframe, offers a compact dismissible notice, retains a loaded app through
transient readiness failure, and defers automatic companion prompts while an
app is active. Four parent lifecycle tests and the frame/intro tests pass;
full-suite/build and actual served-browser checks are still in progress.
V4V preparation on Yaya is complete without replacing the original Portainer
app: consistent private backup of 28 data files and 170 media files, independently
populated managed volumes, preserved existing password hash and pinned managed
image pulled. The original container and volumes remain the rollback path. The
single-node catalog awaits the operator root signature at
`/tmp/archy-yaya-v4v-catalog.json`; no managed app installation is claimed yet.
Launcher qualification update: production build passes. The first full UI run
passed 1,299 tests but timed out in a Bitcoin modal case and then failed its next
case; all 20 targeted tests including both modal cases pass on rerun. A new full
run with reduced concurrency and no simultaneous build is required and running.
No failing full run is recorded as passed.
@@ -153,6 +153,8 @@ import { useLoginTransitionStore } from '@/stores/loginTransition'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{ deferAutomatic?: boolean }>()
const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no
// host to resolve). Points at the companion APK on the release server's https
@@ -200,6 +202,16 @@ const BASE_DELAY_MS = 5000
const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
let baseDelay: ReturnType<typeof setTimeout> | null = null
let disposed = false
let automaticDisplay = false
function clearAutomaticTimers() {
if (baseDelay) clearTimeout(baseDelay)
if (calmTicker) clearInterval(calmTicker)
baseDelay = null
calmTicker = null
}
// Running inside the companion app's own WebView (it injects the JS bridge —
// detected with the canonical helper, not a raw window check, so the gate
@@ -213,9 +225,10 @@ onMounted(async () => {
// The prompt is remembered per APK build, not forever. A browser that saw
// 0.5.28 should be told once when this node begins serving 0.5.32.
await loadCompanionRelease()
if (disposed) return
try {
if (localStorage.getItem(STORAGE_KEY) !== companionReleaseMarker(companionVersion.value)) {
setTimeout(maybeShow, BASE_DELAY_MS)
baseDelay = setTimeout(maybeShow, BASE_DELAY_MS)
}
} catch {
// localStorage unavailable
@@ -223,18 +236,21 @@ onMounted(async () => {
})
onUnmounted(() => {
if (calmTicker) clearInterval(calmTicker)
disposed = true
clearAutomaticTimers()
})
function maybeShow() {
if (disposed) return
clearAutomaticTimers()
// Show only after the scene has been CONTINUOUSLY calm (no reveal
// cinematic) for the full grace window. The previous point-in-time check
// raced a slow-starting reveal — on a cold cache the entrance video can
// begin buffering after the 5s base delay, so the flag was still false
// when sampled and the popup cut straight into the cinematic.
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
let calmSince = loginTransition.introCinematicPlaying || props.deferAutomatic ? null : Date.now()
calmTicker = setInterval(() => {
if (loginTransition.introCinematicPlaying) {
if (loginTransition.introCinematicPlaying || props.deferAutomatic) {
calmSince = null
return
}
@@ -242,11 +258,21 @@ function maybeShow() {
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
if (calmTicker) clearInterval(calmTicker)
calmTicker = null
automaticDisplay = true
visible.value = true
}
}, 250)
}
// A route change can open an app after the automatic offer was displayed.
// Defer that offer until the app closes; never cover its identity/consent UI.
watch(() => props.deferAutomatic, active => {
if (active && visible.value && automaticDisplay) {
visible.value = false
maybeShow()
}
})
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
// and this watcher refuses to open there too, so no caller can ever pop the
@@ -255,10 +281,8 @@ watch(companionIntroRequested, (requested) => {
if (!requested) return
companionIntroRequested.value = false
if (IN_COMPANION_APP) return
if (calmTicker) {
clearInterval(calmTicker)
calmTicker = null
}
clearAutomaticTimers()
automaticDisplay = false
step.value = 'download'
visible.value = true
})
@@ -449,6 +473,7 @@ function showDownloadScreen() {
}
function dismiss() {
clearAutomaticTimers()
visible.value = false
step.value = 'download'
try {
@@ -0,0 +1,41 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import CompanionIntroOverlay from '../CompanionIntroOverlay.vue'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
vi.mock('qrcode', () => ({ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,fixture') }))
vi.mock('@/utils/openExternal', () => ({ isCompanionApp: () => false }))
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false, DEMO_PASSWORD: '' }))
vi.mock('@/composables/useCompanionRelease', async () => {
const { ref } = await import('vue')
return { companionRelease: ref({ versionName: 'fixture', versionCode: 1 }), companionReleaseMarker: () => 'fixture', loadCompanionRelease: vi.fn().mockResolvedValue(undefined) }
})
vi.mock('@/stores/loginTransition', () => ({ useLoginTransitionStore: () => ({ introCinematicPlaying: false }) }))
vi.mock('@/stores/server', () => ({ useServerStore: () => ({}) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('automatic companion offer during app use', () => {
let wrapper: VueWrapper
beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); companionIntroRequested.value = false })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
function render(deferAutomatic = false) { wrapper = mount(CompanionIntroOverlay, { props: { deferAutomatic }, global: { stubs: { Teleport: true, Transition: false } } }) }
it('waits until the app closes before showing its automatic offer', async () => {
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(10000)
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
await wrapper.setProps({ deferAutomatic: false }); await vi.advanceTimersByTimeAsync(2500)
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(true)
await wrapper.setProps({ deferAutomatic: true }); await vi.advanceTimersByTimeAsync(3000)
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
})
it('honors manual opening while an app is active, without reopening after dismissal', async () => {
render(true); await nextTick()
companionIntroRequested.value = true; await nextTick()
await wrapper.get('[aria-label="Close companion modal"]').trigger('click')
await vi.advanceTimersByTimeAsync(12000)
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
})
it('clears deferred timers when the dashboard is unmounted', async () => {
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(1000)
wrapper.unmount(); await vi.advanceTimersByTimeAsync(15000)
expect(vi.getTimerCount()).toBe(0)
})
})
+34 -10
View File
@@ -39,7 +39,8 @@
:must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount"
:refresh-key="refreshKey"
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
:ui-ready-blocked="uiReadyBlocked"
:slow-load="slowLoad"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
:warming-up="warmingUp"
@@ -48,6 +49,7 @@
@iframe-error="onError"
@refresh="refresh"
@open-new-tab-and-back="openNewTabAndBack"
@dismiss-slow-load="slowLoad = false"
/>
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
@@ -162,6 +164,8 @@ const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
const loading = ref(true)
const isRefreshing = ref(false)
const iframeBlocked = ref(false)
const slowLoad = ref(false)
const loadedAppUrl = ref('')
const refreshKey = ref(0)
const showIdentityPicker = ref(false)
const autoRetryCount = ref(0)
@@ -224,7 +228,7 @@ const mustOpenNewTab = computed(() =>
(IS_DEMO && isDemoExternal(appId.value))
)
// The auto-tab detector: the load-timeout marked the frame blocked, the
// The auto-tab detector: an actual frame error marked the frame blocked, the
// warming-up retry loop has given up, and the backend says the app is
// actually RUNNING — that combination is the embed-refusal signature (a
// down app is "warming up" or shows a blocked reason instead). Remember it
@@ -270,6 +274,12 @@ const appUrl = computed(() => {
return canonicalAppUrl(resolveAppUrl(appId.value, deepPath, runtimeUrl))
})
// Readiness protects a first load. A transient background probe must not tear
// down an already loaded login, upload or player and discard its session state.
const uiReadyBlocked = computed(() =>
packageEntry.value?.['ui-ready'] === false && loadedAppUrl.value !== appUrl.value
)
function closeRouteSession() {
const fallback = route.query.returnTo
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
@@ -292,7 +302,11 @@ const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
})
// An actual destination change invalidates consent queued for the previous app page.
watch(appUrl, () => nostrBridge.cancelPending())
watch(appUrl, () => {
loadedAppUrl.value = ''
slowLoad.value = false
nostrBridge.cancelPending()
})
// --- Display mode ---
@@ -385,13 +399,13 @@ const panelClasses = computed(() => {
// A cold/restarting upstream is held outside the iframe. Start one fresh
// load when the scanner observes HTTP readiness; no manual refresh required.
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
if (ready === false) {
watch(uiReadyBlocked, (blocked, previous) => {
if (blocked) {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
loading.value = false
} else if (previous === false && ready === true) {
} else if (previous === true && !blocked) {
autoRetryCount.value = 0
refresh()
}
@@ -400,6 +414,9 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
// --- Lifecycle handlers ---
function onLoad() {
loadedAppUrl.value = appUrl.value
slowLoad.value = false
iframeBlocked.value = false
mediaBridge.connect()
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
@@ -432,6 +449,8 @@ function onLoad() {
}
function onError() {
loadedAppUrl.value = ''
slowLoad.value = false
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
@@ -446,6 +465,8 @@ function onError() {
}
function refresh() {
loadedAppUrl.value = ''
slowLoad.value = false
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
isRefreshing.value = true
loading.value = true
@@ -456,23 +477,26 @@ function refresh() {
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (packageEntry.value?.['ui-ready'] === false) return
if (uiReadyBlocked.value) return
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
iframeBlocked.value = true
isRefreshing.value = false
// Removing the iframe here aborted slow navigation before its load event
// could recover it. Keep the actual frame and offer non-blocking feedback.
slowLoad.value = true
}
}, 12000)
}
function openNewTabAndBack() {
if (packageEntry.value?.['ui-ready'] === false) return
if (uiReadyBlocked.value) return
if (appUrl.value) openExternalUrl(appUrl.value)
closeSession()
}
function openNewTab() {
if (packageEntry.value?.['ui-ready'] === false) return
if (uiReadyBlocked.value) return
if (appUrl.value) openExternalUrl(appUrl.value)
}
+1 -1
View File
@@ -115,7 +115,7 @@
<HealthNotifications />
<!-- First-use companion intro overlay -->
<CompanionIntroOverlay />
<CompanionIntroOverlay :defer-automatic="!!appLauncher.panelAppId || appLauncher.isOpen || route.name === 'app-session'" />
</div>
</template>
@@ -0,0 +1,70 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import AppSession from '../AppSession.vue'
import { useAppStore } from '@/stores/app'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { appId: 'indeedhub' }, query: {}, fullPath: '/dashboard/app-session/indeedhub' }), useRouter: () => ({ replace: vi.fn(() => Promise.resolve()), push: vi.fn(() => Promise.resolve()) }) }))
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ panelAppId: null }) }))
vi.mock('@/stores/app', async () => {
const { reactive } = await import('vue')
const state = reactive({ data: { 'package-data': {} } })
return { useAppStore: () => state }
})
vi.mock('@/stores/screensaver', () => ({ useScreensaverStore: () => ({ suppress: vi.fn(), resume: vi.fn() }) }))
vi.mock('../appSession/useAppIdentity', () => ({ useAppIdentity: () => ({ onIdentitySelected: vi.fn(), onIframeLoadIdentity: vi.fn(), handleIdentityRequest: vi.fn(), getStoredIdentity: () => null, cancelIdentitySelection: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('AppSession slow navigation and readiness changes', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.useFakeTimers(); setActivePinia(createPinia()); localStorage.clear()
Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true })
Object.defineProperty(window, 'location', { value: { hostname: '192.0.2.10', origin: 'http://192.0.2.10', protocol: 'http:' }, configurable: true })
useAppStore().data = { 'package-data': { indeedhub: { state: 'running', 'ui-ready': true, manifest: { id: 'indeedhub', title: 'IndeeHub' } } } } as never
})
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
function render() {
// Keep real Teleport: its test stub freezes the dynamic slot props here.
// Control frame load events separately; AppSessionFrame tests cover its DOM.
wrapper = mount(AppSession, { global: { stubs: { Teleport: false, AppSessionHeader: true, AppSessionFrame: { name: 'AppSessionFrame', props: ['appUrl','loading','iframeBlocked','slowLoad','uiReadyBlocked','refreshKey'], emits: ['iframeLoad','iframeError','refresh'], template: '<div><iframe v-if="!iframeBlocked && !uiReadyBlocked" :key="refreshKey" /></div>' }, NostrIdentityPicker: true, NostrSignConsent: true, MobileGamepad: true, AppLoadingScreen: true } } })
return wrapper.getComponent({ name: 'AppSessionFrame' })
}
function readiness(value: boolean) { useAppStore().data!['package-data'].indeedhub!['ui-ready'] = value }
it('keeps a slow iframe alive past the deadline and accepts its eventual load', async () => {
const frame = render(), original = frame.get('iframe').element
await vi.advanceTimersByTimeAsync(13000); await nextTick()
expect(frame.props('iframeBlocked')).toBe(false)
expect(frame.props('slowLoad')).toBe(true)
expect(frame.get('iframe').element).toBe(original)
frame.vm.$emit('iframeLoad'); await nextTick()
expect(frame.props('slowLoad')).toBe(false)
expect(frame.props('loading')).toBe(false)
expect(frame.get('iframe').element).toBe(original)
})
it('retains a loaded app and frame key through a transient readiness failure', async () => {
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
const original = frame.get('iframe').element, key = frame.props('refreshKey')
readiness(false); await nextTick()
expect(frame.props('uiReadyBlocked')).toBe(false)
expect(frame.get('iframe').element).toBe(original)
readiness(true); await nextTick()
expect(frame.get('iframe').element).toBe(original)
expect(frame.props('refreshKey')).toBe(key)
})
it('still gates a cold app, then starts it once readiness arrives', async () => {
readiness(false); const frame = render()
expect(frame.find('iframe').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(13000); await nextTick()
expect(frame.props('slowLoad')).toBe(false)
readiness(true); await nextTick()
expect(frame.find('iframe').exists()).toBe(true)
expect(frame.props('loading')).toBe(true)
})
it('an explicit refresh rechecks readiness instead of bypassing a cold app', async () => {
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
readiness(false); await nextTick()
frame.vm.$emit('refresh'); await nextTick()
expect(frame.props('uiReadyBlocked')).toBe(true)
expect(frame.find('iframe').exists()).toBe(false)
})
})
@@ -61,6 +61,15 @@
/>
</div>
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status"
class="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-3 py-2 text-xs text-white/75 backdrop-blur-md">
<span class="min-w-0 flex-1">{{ appTitle }} is taking longer to load. You can keep waiting.</span>
<button type="button" class="shrink-0 rounded px-2 py-1 text-white hover:bg-white/10" @click="$emit('refresh')">Retry</button>
<button type="button" class="shrink-0 rounded p-1 text-white/60 hover:bg-white/10" aria-label="Dismiss loading notice" @click="$emit('dismissSlowLoad')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" /></svg>
</button>
</div>
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
reachable yet, so the "App not reachable / retry" overlay would just
@@ -136,6 +145,7 @@ const props = defineProps<{
autoRetryCount: number
refreshKey: number
uiReadyBlocked?: boolean
slowLoad?: boolean
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
@@ -151,6 +161,7 @@ const emit = defineEmits<{
iframeError: []
refresh: []
openNewTabAndBack: []
dismissSlowLoad: []
}>()
const iframeRef = ref<HTMLIFrameElement | null>(null)
@@ -68,6 +68,19 @@ describe('AppSessionFrame warm-up state', () => {
})
describe('HTTP readiness gate', () => {
it('keeps the same iframe while reporting a slow load and lets the user dismiss the notice', async () => {
const frame = mountFrame({ iframeBlocked: false, slowLoad: false });
const original = frame.get('iframe').element;
await frame.setProps({ slowLoad: true });
expect(frame.get('iframe').element).toBe(original);
expect(frame.get('[role="status"]').text()).toContain('Bitcoin is taking longer to load');
await frame.get('[aria-label="Dismiss loading notice"]').trigger('click');
expect(frame.emitted('dismissSlowLoad')).toHaveLength(1);
await frame.setProps({ slowLoad: false });
expect(frame.get('iframe').element).toBe(original);
expect(frame.find('[role="status"]').exists()).toBe(false);
frame.unmount();
})
it('does not show a missing-configuration error during initial installation', () => {
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
expect(frame.text()).not.toContain('App not configured')
+69
View File
@@ -0,0 +1,69 @@
// Real served dashboard, isolated delayed app document; no app mutation or signing.
const fs = require('fs');
const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwright/test');
(async () => {
const origin = process.env.QUALIFICATION_ORIGIN;
const cookieFile = process.env.QUALIFICATION_COOKIES;
const node = process.env.QUALIFICATION_LABEL || 'node';
if (!origin || !cookieFile) throw new Error('Set private node origin and cookie file');
const url = new URL(origin), bytes = url.hostname.split('.').map(Number);
const privateHost = url.hostname === 'localhost' || (bytes.length === 4 && bytes.every(n => Number.isInteger(n) && n >= 0 && n <= 255) &&
(bytes[0] === 127 || bytes[0] === 10 || (bytes[0] === 192 && bytes[1] === 168) || (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] <= 31) || (bytes[0] === 100 && bytes[1] >= 64 && bytes[1] <= 127)));
if (!privateHost || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Refusing non-private node origin');
const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
for (const width of [390, 1440]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
let releaseDocument;
const heldDocument = new Promise(resolve => { releaseDocument = resolve; });
let documentRequests = 0;
try {
const cookies = JSON.parse(fs.readFileSync(cookieFile, 'utf8'));
await context.addCookies(Object.entries(cookies).map(([name, value]) => ({ name, value, url: origin, httpOnly: name !== 'csrf_token' })));
await context.addInitScript(() => {
localStorage.setItem('neode-auth', 'true');
localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000));
});
await context.route('**:7778/**', async route => {
if (route.request().resourceType() !== 'document') return route.abort();
documentRequests++;
await heldDocument;
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body><h1 id="ready">Delayed app ready</h1></body></html>' });
});
// Explicitly deny signing in this loading-only fixture.
await context.route('**/rpc/v1', async route => {
let r; try { r = route.request().postDataJSON(); } catch { return route.continue(); }
if (/nostr-sign|identity.sign|\.(pay|send|spend|melt|withdraw)(-|$)/.test(r?.method || '')) return route.abort();
return route.continue();
});
const page = await context.newPage();
await page.goto(origin + '/dashboard/app-session/indeedhub', { waitUntil: 'domcontentloaded' });
const frame = page.locator('iframe[src*="7778"]');
await expect(frame).toHaveCount(1, { timeout: 30000 });
await frame.evaluate(el => { window.__qualificationFrame = el; });
const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' });
await expect(notice).toBeVisible({ timeout: 20000 });
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0);
releaseDocument();
await expect(page.frameLocator('iframe[src*="7778"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 });
await expect(notice).toHaveCount(0);
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
// Modify readiness only in this isolated browser's store, never the node.
for (const ready of [false, true]) {
await page.evaluate(ready => {
const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app');
app.data['package-data'].indeedhub['ui-ready'] = ready;
}, ready);
await page.waitForTimeout(300);
await expect(frame).toHaveCount(1);
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
}
expect(documentRequests).toBe(1);
console.log(JSON.stringify({ node, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: true, companionOfferDeferred: true, documentRequests }));
} finally {
releaseDocument();
await context.close();
}
}
process.exit(0);
})().catch(e => { console.error(String(e.message).split('Call log:')[0]); process.exit(1); });
+62
View File
@@ -0,0 +1,62 @@
// Real, operator-authorized local IndeeHub login. Only NIP-98 session signatures are allowed.
// Uses isolated browser contexts, does not publish events or perform wallet operations.
const fs=require('fs');const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/test');
(async()=>{
if(process.env.ALLOW_REAL_AUTH_SIGNING!=='1')throw new Error('Set ALLOW_REAL_AUTH_SIGNING=1 only for an authorized real IndeeHub login check');
const origin=process.env.QUALIFICATION_ORIGIN,cookieFile=process.env.QUALIFICATION_COOKIES,node=process.env.QUALIFICATION_LABEL||'node';
if(!origin||!cookieFile)throw new Error('Set private node origin and cookie file');
const u=new URL(origin),octets=u.hostname.split('.').map(Number);
const privateHost=u.hostname==='localhost'||(octets.length===4&&octets.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(octets[0]===127||octets[0]===10||(octets[0]===192&&octets[1]===168)||(octets[0]===172&&octets[1]>=16&&octets[1]<=31)||(octets[0]===100&&octets[1]>=64&&octets[1]<=127)));
if(!privateHost||!['http:','https:'].includes(u.protocol)||u.username||u.password||u.pathname!=='/'||u.search||u.hash)throw new Error('Refusing non-private node origin');
u.port='7778';const appOrigin=u.origin;
const b=await chromium.connectOverCDP(process.env.BROWSER_CDP||'http://127.0.0.1:32911');
for(const width of [390,1440]){
const c=await b.newContext({viewport:{width,height:900},serviceWorkers:'block'});
let page;
const proof={node,width,stage:'setup',realLogin:true,sessionResponses:[],profileResponses:[],signedAuthRequests:0,blockedUnexpectedRequest:false};
try{
const cookies=JSON.parse(fs.readFileSync(cookieFile,'utf8'));await c.addCookies(Object.entries(cookies).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'})));
await c.addInitScript(()=>{localStorage.setItem('neode-auth','true');localStorage.setItem('lnd-seed-backup-prompt-snooze-until',String(Date.now()+3600000));sessionStorage.setItem('indeedhub_splash_shown','true');});
await c.route('**/rpc/v1',async route=>{
let r;try{r=route.request().postDataJSON()}catch{return route.continue()}
if(['identity.nostr-sign','node.nostr-sign'].includes(r?.method)){
const e=r.params?.event;let safe=false;try{const u=new URL(e.tags.find(t=>t[0]==='u')[1]);safe=e.kind===27235&&u.origin===appOrigin&&u.pathname==='/api/auth/nostr/session'&&e.tags.some(t=>t[0]==='method'&&t[1]==='POST')}catch{}
if(!safe){proof.blockedUnexpectedRequest=true;return route.abort()}
proof.signedAuthRequests++;
} else if(r?.method==='identity.sign') {
if(typeof r.params?.message!=='string'||!/^archipelago-identity:\d+$/.test(r.params.message)){proof.blockedUnexpectedRequest=true;return route.abort()}
} else if(/nostr-(encrypt|decrypt)|\.(pay|send|spend|melt|withdraw)(-|$)/.test(r?.method||'')){
proof.blockedUnexpectedRequest=true;return route.abort();
}
return route.continue();
});
const p=await c.newPage();page=p;p.on('response',r=>{const u=new URL(r.url());if(u.origin!==appOrigin)return;if(u.pathname.endsWith('/auth/nostr/session'))proof.sessionResponses.push(r.status());if(u.pathname.endsWith('/auth/me'))proof.profileResponses.push(r.status())});
await p.goto(origin+'/dashboard/app-session/indeedhub',{waitUntil:'domcontentloaded'});
proof.stage='identity selection';
const authenticate=p.getByRole('button',{name:'Authenticate',exact:true});await expect(authenticate).toBeEnabled({timeout:30000});await authenticate.click();
proof.stage='open app sign-in';
const frame=p.frameLocator('iframe[src*="7778"]');
const signIn=frame.getByRole('button',{name:'Sign In',exact:true});await expect(signIn).toBeVisible({timeout:30000});await signIn.click();
proof.stage='choose native signer';
const extension=frame.getByRole('button',{name:'Extension',exact:true});
try{await expect(extension).toBeVisible({timeout:10000});await extension.click()}catch(e){console.log(JSON.stringify({...proof,stage:'auth choices',buttons:await frame.getByRole('button').allTextContents()}));throw e}
proof.stage='approve login consent';
const deadline=Date.now()+45000;let approved=0;
while(Date.now()<deadline){
if(proof.blockedUnexpectedRequest)throw new Error('An unexpected signing/payment request was blocked');
if(proof.sessionResponses.some(s=>s===200||s===201)&&proof.profileResponses.includes(200))break;
const approve=p.getByRole('button',{name:'Approve',exact:true});
if(await approve.isVisible()){if(++approved>6)throw new Error('Unexpected repeated consent');await approve.click();await p.waitForTimeout(800)}else await p.waitForTimeout(250);
}
expect(proof.signedAuthRequests).toBeGreaterThan(0);expect(proof.sessionResponses.some(s=>s===200||s===201)).toBe(true);expect(proof.profileResponses).toContain(200);
await expect(signIn).toHaveCount(0,{timeout:10000});
proof.stage='reload authenticated app';
const profilesBeforeReload=proof.profileResponses.filter(s=>s===200).length;
await p.reload({waitUntil:'domcontentloaded'});
await expect.poll(()=>proof.profileResponses.filter(s=>s===200).length,{timeout:30000}).toBeGreaterThan(profilesBeforeReload);
await expect(frame.getByRole('button',{name:'Sign In',exact:true})).toHaveCount(0,{timeout:10000});
proof.reloadRetainedLogin=true;proof.result='PASS';console.log(JSON.stringify(proof));
}catch(e){console.log(JSON.stringify({...proof,result:'FAIL',path:page?new URL(page.url()).pathname:null,buttons:page?await page.getByRole('button').allTextContents():[]}));if(page)await page.screenshot({path:'/tmp/archy-indeehub-login-'+node+'-'+width+'-failure.png'});throw e}finally{await c.close()}
}
process.exit(0)
})().catch(e=>{console.error(String(e.message).split('Call log:')[0]);process.exit(1)});