diff --git a/docs/indeehub-signer-followup.md b/docs/indeehub-signer-followup.md new file mode 100644 index 00000000..e385c496 --- /dev/null +++ b/docs/indeehub-signer-followup.md @@ -0,0 +1,42 @@ +# IndeeHub native signer follow-up + +## Reproduced on Yaya + +The installed app and dashboard have the same provider SHA256 +`529fe82e7c16b51c62678427f565048c3dd93ca28320bd8494c17236ff57bb81`. +A clean mobile Chromium session opens the identity picker. After selecting the +existing profile and Authenticate, the picker disappears but the broker stays +full-screen (`aria-hidden=false`, 390 by 844). Its document has no visible text +or buttons, and the app still shows Sign In. The trace contains signer-ready and +signer-show but no signer-identity or signer-hide through 18 seconds. + +The picker emits an entry from a Vue reactive array. NostrTabSigner passes that +proxy object directly to cross-frame postMessage after hiding the picker. +Structured cloning rejects reactive proxies, interrupting the handoff before +it schedules the broker hide. Reload uses the already-serialized identity from +localStorage, explaining why refresh can appear to repair the problem. + +## Candidate fix + +Send an explicit plain object containing only the selected public identity +fields. The private signer remains on the node; unrelated metadata is excluded. +Consent behavior and the existing green completion animation are unchanged. + +A regression uses a real Vue reactive identity and structuredClone, verifies +that the proxy itself is rejected, the public handoff is cloneable, metadata is +excluded, and the overlay closes. Eleven focused signer/provider tests pass, +and frontend typecheck passes. A browser qualification using candidate dashboard +assets with the actual Yaya app/auth backend is in progress. No deployment or +actual companion acceptance is claimed yet. + +## App source and further review + +The canonical app is the Vite/Vue source in the separate IndeeHub repository, +not the obsolete Next.js Dockerfile under apps/indeedhub. Its existing local +nginx edit and untracked workflow documentation were preserved; new app work +uses a separate worktree. + +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. diff --git a/docs/post-1.9.0-reliability-investigation.md b/docs/post-1.9.0-reliability-investigation.md index 9eacf2a3..174a0d79 100644 --- a/docs/post-1.9.0-reliability-investigation.md +++ b/docs/post-1.9.0-reliability-investigation.md @@ -101,3 +101,23 @@ nodes before further restarts: only Shorty had an affected message store at the expected paths; its bytes were verified after backup. No message contents or wallet data were exported. Actual candidate build/deployment and store reload acceptance still remain; do not equate source-test success with delivery. + +## Production storage candidate qualification + +The cfd9a596 production binary (SHA256 +3d720c6ddb2622ddef956b5ef0d54ecef64617e4bd16d07327b55ac737d00fe7) +was exercised in the disposable installed-system VM. Independent Python +ChaCha20-Poly1305 fixtures used the guest's key locally, without exporting it. +Encrypted nonces starting with `{` and `[` loaded through the real message RPC, +retained their exact ciphertext, and survived a second service restart. Legacy +JSON with leading whitespace migrated to authenticated ciphertext and survived +another restart with all message fields intact. + +Fixture corrections were required: the first root-owned 0600 file was unreadable +by the service; the next legacy assertion omitted optional fields that serde +normally emits as null. Both initial failures remain in the qualification logs. +The corrected run passed all three data cases. SSH disconnected during final +cleanup, so restoration was completed as a guest systemd job and independently +checked: original 560aa600 backend, active service, and original absent message +store restored. The VM was then shut down. This is not live-fleet deployment or +proof of every corrupt-store recovery path. diff --git a/neode-ui/src/views/NostrTabSigner.vue b/neode-ui/src/views/NostrTabSigner.vue index 8fa4830d..4f1d5f42 100644 --- a/neode-ui/src/views/NostrTabSigner.vue +++ b/neode-ui/src/views/NostrTabSigner.vue @@ -74,7 +74,19 @@ function hideSigner(delay = 0) { } function sendIdentity(identity: SelectedIdentity) { - parentPost({ type: 'archipelago:signer-identity', identity }) + // The picker emits a Vue reactive object. Browser postMessage cannot clone + // that proxy: sending it closes the picker, then throws before the broker + // can hide, leaving a blank full-screen surface. Send public scalar fields + // explicitly, also keeping any unrelated identity metadata out of the app. + const publicIdentity: SelectedIdentity = { + id: identity.id, + name: identity.name, + did: identity.did, + pubkey: identity.pubkey, + nostr_pubkey: identity.nostr_pubkey, + nostr_npub: identity.nostr_npub, + } + parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity }) } const bridge = useNostrBridge(getStoredIdentity, { diff --git a/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts b/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts index fbab01ae..5874cac1 100644 --- a/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts +++ b/neode-ui/src/views/appSession/__tests__/NostrTabSigner.test.ts @@ -1,5 +1,7 @@ -import { shallowMount } from '@vue/test-utils' +import { flushPromises, shallowMount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { reactive } from 'vue' +import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue' import NostrTabSigner from '@/views/NostrTabSigner.vue' describe('NostrTabSigner visibility', () => { @@ -8,7 +10,7 @@ describe('NostrTabSigner visibility', () => { window.history.replaceState({}, '', '/nostr-signer') }) - afterEach(() => vi.restoreAllMocks()) + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() }) function parentMessage(data: Record) { const event = new MessageEvent('message', { data, origin: window.location.origin }) @@ -46,4 +48,33 @@ describe('NostrTabSigner visibility', () => { expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false) expect(document.body.classList.contains('nostr-signer-route')).toBe(false) }) + + it('hands off a reactive picker identity as cloneable public fields and releases the overlay', async () => { + vi.useFakeTimers() + const sent: Array> = [] + // Real cross-frame postMessage performs structured cloning. A normal spy + // would miss the browser-only DataCloneError that leaves a grey overlay. + vi.spyOn(window.parent, 'postMessage').mockImplementation((message) => { + sent.push(structuredClone(message)) + }) + const wrapper = shallowMount(NostrTabSigner) + parentMessage({type: 'archipelago:signer-init', appId: 'indeedhub', appName: 'IndeeHub'}) + const selected = reactive({ + id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key', + nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test', internal_metadata: 'must-stay-local', + }) + expect(() => structuredClone(selected)).toThrow() + wrapper.findComponent(NostrIdentityPicker).vm.$emit('select', selected) + await flushPromises() + const identityMessage = sent.find(message => message.type === 'archipelago:signer-identity') + expect(identityMessage?.identity).toEqual({ + id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key', + nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test', + }) + await vi.advanceTimersByTimeAsync(400) + expect(sent).toContainEqual({type: 'archipelago:signer-hide'}) + expect(wrapper.findComponent(NostrIdentityPicker).props('show')).toBe(false) + wrapper.unmount() + }) + })