fix: retain native signer session through repeated public-key lookups

This commit is contained in:
archipelago
2026-10-05 21:18:27 -04:00
parent 6f098cd9c2
commit 3d0c67eb9b
4 changed files with 80 additions and 7 deletions
+22
View File
@@ -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 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 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. 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.
+10 -1
View File
@@ -211,7 +211,16 @@
selectedPublicKeyTimer = null; selectedPublicKeyTimer = null;
return Promise.resolve(publicKey); 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 selectIdentity().then(function () {
return getPublicKey(); return getPublicKey();
}); });
@@ -26,6 +26,7 @@ describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow let providerWindow: ProviderWindow
beforeEach(() => { beforeEach(() => {
sessionStorage.clear()
providerWindow = window as ProviderWindow providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr delete providerWindow.__archipelagoNostr
delete providerWindow.nostr 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 () => { it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = { const surface = {
expectPageTransition: vi.fn(), expectPageTransition: vi.fn(),
@@ -15,12 +15,13 @@ describe('fleet unavailable readings', () => {
global: {mocks: {$ver: (v: string) => v}}, global: {mocks: {$ver: (v: string) => v}},
}) })
const cards = wrapper.findAll('.fleet-node-card') const cards = wrapper.findAll('.fleet-node-card')
expect(cards[0].text()).toContain('0%') expect(cards).toHaveLength(2)
expect(cards[0].text()).toContain('—') expect(cards[0]!.text()).toContain('0%')
expect(cards[0].text()).toContain('Offline · last seen 2d ago') expect(cards[0]!.text()).toContain('—')
expect(cards[1].text()).toContain('Status unknown') expect(cards[0]!.text()).toContain('Offline · last seen 2d ago')
expect(cards[1].text()).not.toContain('0%') expect(cards[1]!.text()).toContain('Status unknown')
expect(cards[1].text()).toContain('Uptime unavailable') expect(cards[1]!.text()).not.toContain('0%')
expect(cards[1]!.text()).toContain('Uptime unavailable')
expect(wrapper.text()).not.toContain('NaN') expect(wrapper.text()).not.toContain('NaN')
wrapper.unmount() wrapper.unmount()
}) })