From 3d0c67eb9b30a3a3b35f27f2c1611e04900926a4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 21:18:27 -0400 Subject: [PATCH] fix: retain native signer session through repeated public-key lookups --- docs/indeehub-signer-followup.md | 22 ++++++++++ neode-ui/public/nostr-provider.js | 11 ++++- .../__tests__/nostrProviderIdentity.test.ts | 41 +++++++++++++++++++ .../fleet/__tests__/FleetNodeGrid.test.ts | 13 +++--- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/indeehub-signer-followup.md b/docs/indeehub-signer-followup.md index e385c496..c5cd44a1 100644 --- a/docs/indeehub-signer-followup.md +++ b/docs/indeehub-signer-followup.md @@ -40,3 +40,25 @@ Review found additional app-side concerns to test: production network failures can flip the app into mock mode and fabricate subscribed users, and the header can discard a valid backend Nostr session when the local signer account has not been restored. Do not claim these repaired by the broker object-cloning fix. + +## Live candidate qualification and restored-session prompting + +2026-10-05: actual Yaya NIP-98 session exchange returns201 and authenticated +profile returns200 using candidate dashboard and app assets. The overlay hides; +a full refresh reuses the session and profile without another auth exchange. +Evidence: /tmp/archy-indeehub-full-candidate-2.log. This uses headless Chromium +390x844, real cookies/signatures/RPC/API, with only static candidate assets routed +locally. Initial asset-routing attempts hit Chromium private-network checks; +forwarding real API requests in the fixture resolved that test harness failure. +No backend authentication was mocked or bypassed. + +Public-key lookups can inherit transient activation from the identity-picker +click/reload. The provider now avoids interpreting a restored-session hint or +already selected identity as a new account-switch request. Requests still pass +to the authenticated broker; the hint grants no signature permission. Explicit +selectIdentity remains available. Twelve provider/tab-signer regressions pass. + +The combined frontend production check found strict TypeScript nullability +errors in Fleet test array indexing; the fixture now asserts both cards exist +and uses non-null indexing. No production Fleet behavior changed in that repair. +Actual deployment, companion lifecycle and the remaining recovery cases stay open. diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index 804d080d..c27fe2a3 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -211,7 +211,16 @@ selectedPublicKeyTimer = null; return Promise.resolve(publicKey); } - if (navigator.userActivation && navigator.userActivation.isActive) { + // The picker click itself leaves transient user activation active. A second + // public-key lookup in the same login must not open another picker. + var restoringSession = false; + try { + var sessionHint = sessionStorage.getItem('nostr_token'); + restoringSession = !!sessionHint && sessionHint.indexOf('mock-') !== 0; + } catch (_) {} + // This is only a UI hint: the broker still verifies the node session and + // signing permissions. A restored app token never authorizes a signature. + if (!restoringSession && !selectedIdentity && navigator.userActivation && navigator.userActivation.isActive) { return selectIdentity().then(function () { return getPublicKey(); }); diff --git a/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts index fbd4f818..bfc95cd8 100644 --- a/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts +++ b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts @@ -26,6 +26,7 @@ describe('nostr-provider identity selection', () => { let providerWindow: ProviderWindow beforeEach(() => { + sessionStorage.clear() providerWindow = window as ProviderWindow delete providerWindow.__archipelagoNostr delete providerWindow.nostr @@ -122,6 +123,46 @@ describe('nostr-provider identity selection', () => { ) }) + it('does not reopen after a selected identity when activation survives account restoration', async () => { + const { frame, postMessage, signerOrigin } = loadProvider(true) + const selected = new MessageEvent('message', { + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'selected-key' } }, + origin: signerOrigin, + }) + Object.defineProperty(selected, 'source', { value: frame.contentWindow }) + window.dispatchEvent(selected) + await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key') + postMessage.mockClear() + const next = providerWindow.nostr!.getPublicKey() + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(), + ) + const request = postMessage.mock.calls[0]![0] as { id: number } + const response = new MessageEvent('message', { + data: { type: 'nostr-response', id: request.id, result: 'selected-key' }, origin: signerOrigin, + }) + Object.defineProperty(response, 'source', { value: frame.contentWindow }) + window.dispatchEvent(response) + await expect(next).resolves.toBe('selected-key') + // Explicit account switching remains available after a successful login. + void providerWindow.archipelagoNostr!.selectIdentity() + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin, + ) + }) + + it('does not mistake reload activation for a new login while restoring a session', () => { + sessionStorage.setItem('nostr_token', 'test-session-hint') + const { postMessage, signerOrigin } = loadProvider(true) + 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('parks the hidden broker off-screen and reuses it for the next request', async () => { const surface = { expectPageTransition: vi.fn(), diff --git a/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts b/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts index 924ebe85..e0091196 100644 --- a/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts +++ b/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts @@ -15,12 +15,13 @@ describe('fleet unavailable readings', () => { global: {mocks: {$ver: (v: string) => v}}, }) const cards = wrapper.findAll('.fleet-node-card') - expect(cards[0].text()).toContain('0%') - expect(cards[0].text()).toContain('—') - expect(cards[0].text()).toContain('Offline · last seen 2d ago') - expect(cards[1].text()).toContain('Status unknown') - expect(cards[1].text()).not.toContain('0%') - expect(cards[1].text()).toContain('Uptime unavailable') + expect(cards).toHaveLength(2) + expect(cards[0]!.text()).toContain('0%') + expect(cards[0]!.text()).toContain('—') + expect(cards[0]!.text()).toContain('Offline · last seen 2d ago') + expect(cards[1]!.text()).toContain('Status unknown') + expect(cards[1]!.text()).not.toContain('0%') + expect(cards[1]!.text()).toContain('Uptime unavailable') expect(wrapper.text()).not.toContain('NaN') wrapper.unmount() })