Merge commit '6297c3733b1a2f988c0bc3a596d16113562d86a9' into work/jw-native-reviewed

This commit is contained in:
archipelago
2026-10-08 14:15:21 -04:00
11 changed files with 417 additions and 70 deletions
+2
View File
@@ -73,6 +73,8 @@ app:
- exec: ["sh", "-c", "if ! grep -qE '<script[^>]*nostr-provider' /usr/share/nginx/html/index.html && ! grep -qE '(sub_filter|<script).*nostr-provider' /etc/nginx/conf.d/default.conf; then sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /usr/share/nginx/html/index.html; fi"]
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
# Compose the outer app-proxy prefix for NIP-98 signed URL verification.
- exec: ["sed", "-i", "s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|", "/etc/nginx/conf.d/default.conf"]
- exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
+94 -27
View File
@@ -144,6 +144,7 @@
// call. Keeping this as an optional companion API preserves NIP-07 compatibility
// while allowing users to change their node identity when they log in again.
function selectIdentity() {
autoAuthSuspended = false;
if (identitySelection) {
identitySelection.reject(new Error('A node identity choice is already open'));
clearTimeout(identitySelection.timer);
@@ -164,21 +165,86 @@
});
}
// JWT claims are a reuse hint only; the API still verifies the signature.
// Never let an unrelated or expired app session override the chosen node key.
function tokenMatchesIdentity(token, pubkey) {
try {
if (typeof token !== 'string' || !pubkey) return false;
var parts = token.split('.');
if (parts.length !== 3 || !parts.every(function (part) { return /^[A-Za-z0-9_-]+$/.test(part); })) return false;
var encoded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (encoded.length % 4) encoded += '=';
var claims = JSON.parse(atob(encoded));
return claims.sub === pubkey && typeof claims.exp === 'number' &&
Number.isFinite(claims.exp) && claims.exp > Date.now() / 1000;
} catch (_) { return false; }
}
var authGeneration = 0, authAttempt = null, autoAuthTimer = null, autoAuthSuspended = false;
function clearSession() {
authGeneration++;
authAttempt = null;
clearTimeout(autoAuthTimer);
autoAuthSuspended = true;
var previous = selectedIdentity && selectedIdentity.nostr_pubkey;
selectedIdentity = null; selectedPublicKey = null;
clearTimeout(selectedPublicKeyTimer);
window.dispatchEvent(new CustomEvent('archipelago:identity-changing', { detail: { nostr_pubkey: null, previous_nostr_pubkey: previous } }));
// These are app session credentials, not stored accounts, profiles or keys.
try {
['nostr_token', 'nostr_pubkey', 'refresh_token'].forEach(function (key) { sessionStorage.removeItem(key); });
} catch (_) {}
}
function scheduleIdentityAuth(pubkey) {
if (!autoNip98 || autoAuthSuspended) return;
try { if (tokenMatchesIdentity(sessionStorage.getItem('nostr_token'), pubkey)) return; } catch (_) {}
clearTimeout(autoAuthTimer);
var generation = authGeneration;
autoAuthTimer = setTimeout(function () {
if (generation === authGeneration && selectedIdentity && selectedIdentity.nostr_pubkey === pubkey) doNip98Auth(pubkey, generation);
}, 1500);
}
function finishIdentitySelection(identity) {
// The identity picker is itself an explicit choice to disclose this key.
// Keep it briefly so the login library's immediately-following
// getPublicKey() does not depend on another cross-origin WebView round trip.
// This is deliberately one-shot and short-lived.
if (identity && typeof identity.nostr_pubkey === 'string' && identity.nostr_pubkey) {
if (identity && typeof identity.nostr_pubkey === 'string' && /^[0-9a-f]{64}$/.test(identity.nostr_pubkey)) {
var previous = selectedIdentity && selectedIdentity.nostr_pubkey;
var changed = previous !== identity.nostr_pubkey;
var storedToken = null;
try { storedToken = sessionStorage.getItem('nostr_token'); } catch (_) {}
var invalidSession = !!storedToken && !tokenMatchesIdentity(storedToken, identity.nostr_pubkey);
selectedIdentity = { nostr_pubkey: identity.nostr_pubkey };
var displayName = identity.display_name || identity.name;
if (typeof displayName === 'string' && displayName.trim()) selectedIdentity.display_name = displayName.trim().slice(0, 160);
if (changed || (invalidSession && !authAttempt)) {
authGeneration++;
authAttempt = null;
clearTimeout(autoAuthTimer);
window.dispatchEvent(new CustomEvent('archipelago:identity-changing', { detail: {
nostr_pubkey: identity.nostr_pubkey, previous_nostr_pubkey: previous,
} }));
}
try {
if (!tokenMatchesIdentity(sessionStorage.getItem('nostr_token'), identity.nostr_pubkey)) {
['nostr_token', 'nostr_pubkey', 'refresh_token'].forEach(function (key) { sessionStorage.removeItem(key); });
} else {
if (sessionStorage.getItem('nostr_pubkey') !== identity.nostr_pubkey) sessionStorage.removeItem('refresh_token');
sessionStorage.setItem('nostr_pubkey', identity.nostr_pubkey);
}
} catch (_) {}
selectedPublicKey = identity.nostr_pubkey;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = setTimeout(function () {
selectedPublicKey = null;
selectedPublicKeyTimer = null;
}, 15000);
scheduleIdentityAuth(identity.nostr_pubkey);
identitySubscribers.slice().forEach(function (subscriber) {
try { subscriber(selectedIdentity); } catch (error) {
try { subscriber(getSelectedIdentity()); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
});
@@ -187,7 +253,8 @@
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.resolve(identity);
if (!identity || !/^[0-9a-f]{64}$/.test(identity.nostr_pubkey)) selection.reject(new Error('Invalid native public key'));
else selection.resolve(getSelectedIdentity());
}
function cancelIdentitySelection() {
@@ -216,7 +283,7 @@
var restoringSession = false;
try {
var sessionHint = sessionStorage.getItem('nostr_token');
restoringSession = !!sessionHint && sessionHint.indexOf('mock-') !== 0;
restoringSession = !!selectedIdentity && tokenMatchesIdentity(sessionHint, selectedIdentity.nostr_pubkey);
} 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.
@@ -237,7 +304,7 @@
}
identitySubscribers.push(subscriber);
if (selectedIdentity) {
try { subscriber(selectedIdentity); } catch (error) {
try { subscriber(getSelectedIdentity()); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
}
@@ -249,12 +316,12 @@
}
function getSelectedIdentity() {
return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey };
return selectedIdentity && Object.assign({}, selectedIdentity);
}
window.addEventListener('message', function (e) {
var validSource = embedded
? e.source === window.parent
? e.source === window.parent && e.origin === dashboardOrigin()
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
if (!validSource || !e.data) return;
@@ -275,7 +342,8 @@
finishIdentitySelection(e.data.identity);
window.postMessage({
type: 'archipelago:identity',
nostr_pubkey: e.data.identity && e.data.identity.nostr_pubkey,
nostr_pubkey: selectedIdentity && selectedIdentity.nostr_pubkey,
display_name: selectedIdentity && selectedIdentity.display_name,
}, window.location.origin);
return;
}
@@ -391,16 +459,21 @@
selectIdentity: selectIdentity,
onIdentitySelected: onIdentitySelected,
getSelectedIdentity: getSelectedIdentity,
clearSession: clearSession,
};
// Optional direct NIP-98 session bootstrap for apps that use it. Signing
// itself is shown by the shared broker, so this deliberately adds no second
// full-screen loader inside the app.
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
function doNip98Auth(pubkey, generation) {
if (authAttempt || autoAuthSuspended) return;
var attempt = {};
authAttempt = attempt;
function current() {
return authAttempt === attempt && generation === authGeneration && !autoAuthSuspended &&
selectedIdentity && selectedIdentity.nostr_pubkey === pubkey;
}
function requireCurrent() { if (!current()) throw new Error('Identity choice changed'); }
var healthUrl = window.location.origin + '/api/nostr-auth/health';
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
var healthController = new AbortController();
@@ -408,6 +481,7 @@
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
clearTimeout(healthTimeout);
requireCurrent();
if (!response.ok) throw new Error('Health ' + response.status);
return window.nostr.signEvent({
kind: 27235,
@@ -417,6 +491,8 @@
tags: [['u', sessionUrl], ['method', 'POST']],
});
}).then(function (signed) {
requireCurrent();
if (!signed || signed.pubkey !== pubkey) throw new Error('Signer identity differs from selected identity');
var controller = new AbortController();
setTimeout(function () { controller.abort(); }, 10000);
return fetch(sessionUrl, {
@@ -428,10 +504,12 @@
if (!response.ok) throw new Error('Auth failed: ' + response.status);
return response.json();
}).then(function (data) {
if (!data.accessToken) throw new Error('Authentication returned no access token');
requireCurrent();
if (!tokenMatchesIdentity(data.accessToken, pubkey)) throw new Error('Authentication returned an expired or differently bound session');
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
else sessionStorage.removeItem('refresh_token');
return waitForSignerToHide().then(function () {
// Give WebView one paint after the iframe is hidden before replacing
// the document. The stored session is already durable at this point.
@@ -441,6 +519,7 @@
});
});
}).then(function () {
requireCurrent();
if (window.ArchipelagoSurface &&
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
window.ArchipelagoSurface.expectPageTransition();
@@ -448,25 +527,13 @@
window.location.reload();
});
}).catch(function (error) {
authDone = false;
if (authAttempt === attempt) authAttempt = null;
var message = error && error.message ? error.message : String(error);
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', message);
});
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return;
if (e.source !== window && e.source !== window.parent) return;
var pubkey = e.data.nostr_pubkey;
if (!pubkey) return;
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) return;
} catch (_) {}
setTimeout(function () { doNip98Auth(pubkey); }, 1500);
});
// Only identity-aware apps open the chooser eagerly. The provider is also
// injected into several ordinary app proxies; those stay untouched unless
// they actually invoke a NIP-07 method, which lazily creates the broker.
@@ -41,6 +41,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { secureUuid } from '@/utils/secureUuid'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RegistrationRequest, CloudSelection } from '@/composables/useMediaRegistrationBridge'
@@ -48,7 +49,7 @@ const props = defineProps<{ request: RegistrationRequest | null; phase: 'select'
const emit = defineEmits<{ approve: [selection: CloudSelection]; cancel: []; resolve: [] }>()
const modal = ref<HTMLElement | null>(null), directory = ref('/'), files = ref<FileBrowserItem[]>([])
const selected = ref<FileBrowserItem | null>(null), loading = ref(false), localError = ref('')
const titleId = `media-registration-${crypto.randomUUID()}`
const titleId = `media-registration-${secureUuid()}`
let generation = 0
const visible = computed(() => files.value.filter(item => item.isDir || /\.(mp4|m4v|webm|mov)$/i.test(item.name)))
const duration = computed(() => { const seconds = props.request?.intent.viewingSeconds ?? 0; return seconds % 3600 === 0 ? `${seconds / 3600} hours` : `${Math.ceil(seconds / 60)} minutes` })
@@ -0,0 +1,19 @@
import { mount, flushPromises } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
const files = vi.hoisted(() => ({ login: vi.fn(async () => true), listDirectory: vi.fn(async () => []) }))
vi.mock('@/api/filebrowser-client', () => ({ fileBrowserClient: files }))
import MediaRegistrationConsent from '../MediaRegistrationConsent.vue'
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
it('mounts the inactive dashboard consent and opens its labelled dialog on LAN HTTP', async () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const wrapper = mount(MediaRegistrationConsent, { props: { request: null, phase: 'select', error: '' } })
expect(files.login).not.toHaveBeenCalled()
await wrapper.setProps({ request: { intent: { viewingSeconds: 3600, priceSats: 15 } } as any })
await flushPromises()
const dialog = wrapper.get('[role="dialog"]')
const label = dialog.attributes('aria-labelledby')
expect(label).toMatch(/^media-registration-[0-9a-f-]{36}$/)
expect(wrapper.get('#' + label).text()).toBeTruthy()
wrapper.unmount()
})
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc }))
import { webcrypto } from 'node:crypto'
import { installedOriginMatches, useMediaRegistrationBridge } from '../useMediaRegistrationBridge'
const intent = { version: 1 as const, requestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', nonce: 'a'.repeat(64),
@@ -27,6 +28,17 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('native Cloud registration ownership and interrupted operation boundary', () => {
it('persists and resumes the same secure approval on LAN HTTP without randomUUID', async () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const f = fixture(); await f.send('select'); f.bridge.approve(selection)
const approved = f.child.postMessage.mock.lastCall![0].result
expect(approved.approvalId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
f.bridge.dispose()
const resumed = fixture(); await resumed.send('resume')
expect(resumed.child.postMessage.mock.lastCall![0].result).toEqual(approved)
resumed.bridge.dispose()
})
it('ignores other windows/origins and checks installer scope before showing Cloud selection', async () => {
const f = fixture()
await f.send('select', {}, 'https://evil.test'); await f.send('select', {}, undefined, {})
@@ -93,6 +105,7 @@ describe('native Cloud registration ownership and interrupted operation boundary
})
it('recovers resolution approval across reload and cannot use it to prepare a file', async () => {
vi.mocked(Date.now).mockReturnValue(1700_000)
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const first = fixture(); await first.send('resolve')
expect(first.bridge.phase.value).toBe('resolve')
first.bridge.approveResolution()
+2 -10
View File
@@ -1,18 +1,10 @@
import { onBeforeUnmount, watch } from 'vue'
import { useAudioPlayer } from './useAudioPlayer'
import { secureUuid } from '@/utils/secureUuid'
type AudioBridge = { postMessage: (message: string) => void; onmessage: ((event: { data: string }) => void) | null }
type NativeWindow = Window & { ArchipelagoAudio?: AudioBridge }
/** LAN HTTP WebViews expose getRandomValues, but not secure-context randomUUID. */
function audioSessionId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
if (!url) return ''
@@ -100,7 +92,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
if (!player.currentSrc.value || !player.currentName.value) { release(); return }
if (!session || source !== player.currentSrc.value) {
if (!player.playing.value) { release(); return }
release(); session = audioSessionId(); source = player.currentSrc.value; sequence = 0
release(); session = secureUuid(); source = player.currentSrc.value; sequence = 0
loadArtwork()
}
const duration = Number.isFinite(player.duration.value) ? Math.max(0, Math.min(player.duration.value, 604800)) : 0
@@ -1,4 +1,5 @@
import { ref, shallowRef } from 'vue'
import { secureUuid } from '@/utils/secureUuid'
import { rpcClient } from '@/api/rpc-client'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
@@ -102,7 +103,7 @@ export function useMediaRegistrationBridge(context: FrameContext) {
const old = savedApproval(pending.intent)
if (old && !exact(old.selection, selection)) throw new Error('This operation already approved another file. Resume its original selection.')
const saved: SavedApproval = old ?? { version: 1, intent: pending.intent, selection: structuredClone(selection),
approvalId: crypto.randomUUID(), event: { kind: 27236, created_at: Math.floor(Date.now() / 1000),
approvalId: secureUuid(), event: { kind: 27236, created_at: Math.floor(Date.now() / 1000),
tags: [['d', REGISTRATION_SCOPE]], content: JSON.stringify(approvalContent(pending.intent, selection), null, 2) } }
// Persist owner approval before replying. Storage failure cannot silently
// turn a later retry into a newly approved file or a replacement operation.
@@ -114,7 +115,7 @@ export function useMediaRegistrationBridge(context: FrameContext) {
}
function approveResolution() {
if (!pending || pending.mode !== 'resolve' || phase.value !== 'resolve' || !current(pending)) return
const approved = { intent: pending.intent, approvalId: crypto.randomUUID(), event: {
const approved = { intent: pending.intent, approvalId: secureUuid(), event: {
kind: 27237, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'archipelago.media-registration.resolution.v1']],
content: JSON.stringify({ action: 'Recover prepared video or retire this expired incomplete registration',
scope: 'archipelago.media-registration.resolution.v1', intent: pending.intent }, null, 2),
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
import { secureUuid } from '../secureUuid'
afterEach(() => vi.unstubAllGlobals())
describe('secure UUIDs across dashboard origins', () => {
it('uses the native secure-context API with its receiver', () => {
const source = { randomUUID() { expect(this).toBe(source); return 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, getRandomValues: vi.fn() }
vi.stubGlobal('crypto', source)
expect(secureUuid()).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
expect(source.getRandomValues).not.toHaveBeenCalled()
})
it('creates distinct UUIDs on LAN HTTP using only getRandomValues', () => {
vi.stubGlobal('crypto', { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) })
const ids = Array.from({ length: 32 }, secureUuid)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
it('sets the UUID version and variant without losing other random bits', () => {
vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => bytes.fill(255) })
expect(secureUuid()).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff')
})
it.each([undefined, {}])('refuses when secure randomness is unavailable (%s)', source => {
vi.stubGlobal('crypto', source)
expect(() => secureUuid()).toThrow('Secure randomness is unavailable.')
})
})
+13
View File
@@ -0,0 +1,13 @@
/** Secure UUIDs also work on LAN HTTP, where randomUUID is unavailable. */
export function secureUuid(): string {
const source = globalThis.crypto
if (typeof source?.randomUUID === 'function') return source.randomUUID()
if (typeof source?.getRandomValues !== 'function') {
throw new Error('Secure randomness is unavailable.')
}
const bytes = source.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
@@ -14,6 +14,7 @@ type ProviderWindow = Window & {
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
clearSession: () => void
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
@@ -57,6 +58,18 @@ describe('nostr-provider identity selection', () => {
delete providerWindow.ArchipelagoSurface
})
function token(pubkey: string, exp = Math.floor(Date.now() / 1000) + 3600) {
return `e30.${btoa(JSON.stringify({ sub: pubkey, exp })).replace(/=+$/, '')}.signature`
}
function choose(frame: HTMLIFrameElement, origin: string, pubkey: string, display_name?: string) {
const event = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: pubkey, display_name } }, origin,
})
Object.defineProperty(event, 'source', { value: frame.contentWindow })
window.dispatchEvent(event)
}
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
@@ -89,14 +102,14 @@ describe('nostr-provider identity selection', () => {
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
await expect(publicKey).resolves.toBe('6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
@@ -120,14 +133,14 @@ describe('nostr-provider identity selection', () => {
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'd930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('d930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
@@ -137,12 +150,12 @@ 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' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key')
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e')
postMessage.mockClear()
const next = providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalledWith(
@@ -150,11 +163,11 @@ describe('nostr-provider identity selection', () => {
)
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,
data: { type: 'nostr-response', id: request.id, result: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' }, origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: frame.contentWindow })
window.dispatchEvent(response)
await expect(next).resolves.toBe('selected-key')
await expect(next).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e')
// Explicit account switching remains available after a successful login.
void providerWindow.archipelagoNostr!.selectIdentity()
expect(postMessage).toHaveBeenCalledWith(
@@ -162,15 +175,12 @@ describe('nostr-provider identity selection', () => {
)
})
it('does not mistake reload activation for a new login while restoring a session', () => {
it('does not let an unbound token suppress an explicit login choice', () => {
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(),
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), signerOrigin,
)
})
@@ -219,18 +229,18 @@ describe('nostr-provider identity selection', () => {
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
data: { type: 'nostr-response', id: request.id, result: 'f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
await expect(publicKey).resolves.toBe('f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
@@ -240,13 +250,13 @@ describe('nostr-provider identity selection', () => {
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
.toEqual({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '7dbe01f62123a375cdfd5c92c78408db35005175e99fed1aa05d74b91df13f36' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
@@ -258,13 +268,13 @@ describe('nostr-provider identity selection', () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(identity, 'source', { value: window })
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
@@ -276,7 +286,7 @@ describe('nostr-provider identity selection', () => {
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
params: { event: expect.objectContaining({ kind: 27235, pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' }) },
}),
signerOrigin,
)
@@ -288,17 +298,17 @@ describe('nostr-provider identity selection', () => {
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
json: () => Promise.resolve({ accessToken: token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'), refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(identity, 'source', { value: window })
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
@@ -313,14 +323,14 @@ describe('nostr-provider identity selection', () => {
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event', pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(sessionStorage.getItem('nostr_token')).toBe(token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'))
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
@@ -363,4 +373,163 @@ describe('nostr-provider identity selection', () => {
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
it.each(['expired', 'wrong-key', 'malformed'])('replaces a %s token without deleting stored accounts', async kind => {
const original = kind === 'expired' ? token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 1) : kind === 'wrong-key' ? token('d9298a10d1b0735837dc4bd85dac641b0f3cef27a47e5d53a54f2f3f5b2fcffa') : 'invalid'
sessionStorage.setItem('nostr_token', original)
sessionStorage.setItem('refresh_token', 'old-refresh')
localStorage.setItem('saved-account-fixture', 'private-key-and-profile-fixture')
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
expect(sessionStorage.getItem('nostr_token')).toBeNull()
expect(sessionStorage.getItem('refresh_token')).toBeNull()
expect(localStorage.getItem('saved-account-fixture')).toBe('private-key-and-profile-fixture')
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledOnce()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ method: 'signEvent' }), signerOrigin)
localStorage.removeItem('saved-account-fixture')
})
it('reuses only an unexpired token matching the verified selected identity', async () => {
const saved = token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
sessionStorage.setItem('nostr_token', saved)
sessionStorage.setItem('nostr_pubkey', 'stale-key')
sessionStorage.setItem('refresh_token', 'stale-refresh')
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 'My native profile')
await vi.advanceTimersByTimeAsync(1600)
expect(fetchMock).not.toHaveBeenCalled()
expect(sessionStorage.getItem('nostr_token')).toBe(saved)
expect(sessionStorage.getItem('nostr_pubkey')).toBe('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
expect(sessionStorage.getItem('refresh_token')).toBeNull()
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toEqual({ nostr_pubkey: '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', display_name: 'My native profile' })
})
it('ignores self-forged identity messages and signer messages from another origin', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame } = loadProvider(false)
choose(frame, 'https://untrusted.example', 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30')
const event = new MessageEvent('message', { data: { type: 'archipelago:identity', nostr_pubkey: 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30' }, origin: location.origin })
Object.defineProperty(event, 'source', { value: window })
window.dispatchEvent(event)
await vi.advanceTimersByTimeAsync(1600)
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['switch', 'logout'])('does not store an in-flight old session after %s', async action => {
let complete!: (response: unknown) => void
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4')
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4' } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
if (action === 'switch') choose(frame, signerOrigin, '11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437')
else providerWindow.archipelagoNostr!.clearSession()
complete({ ok: true, json: async () => ({ accessToken: token('cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4'), refreshToken: 'old-refresh' }) })
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBeNull()
expect(sessionStorage.getItem('refresh_token')).toBeNull()
})
it('announces a verified switch before clearing the old session and exposes only public metadata', () => {
const oldKey = 'a'.repeat(64), nextKey = 'b'.repeat(64)
sessionStorage.setItem('nostr_token', token(oldKey))
const { frame, signerOrigin } = loadProvider(false)
const changes: unknown[] = []
window.addEventListener('archipelago:identity-changing', event => {
changes.push({ detail: (event as CustomEvent).detail, token: sessionStorage.getItem('nostr_token'), selected: providerWindow.archipelagoNostr!.getSelectedIdentity() })
})
choose(frame, signerOrigin, nextKey, 'Chosen profile')
expect(changes).toEqual([{ detail: { nostr_pubkey: nextKey, previous_nostr_pubkey: null }, token: token(oldKey), selected: { nostr_pubkey: nextKey, display_name: 'Chosen profile' } }])
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('rejects a malformed native identity without exposing it or starting authentication', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, 'not-a-native-public-key')
await vi.advanceTimersByTimeAsync(1600)
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('clears a now-expired token on repeated selection of the same identity', () => {
const key = 'a'.repeat(64)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, key)
sessionStorage.setItem('nostr_token', token(key, 1))
const notify = vi.fn()
window.addEventListener('archipelago:identity-changing', notify)
choose(frame, signerOrigin, key)
expect(notify).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('keeps logout quiet and permits a subsequent explicit identity choice', async () => {
const key = 'a'.repeat(64)
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
providerWindow.archipelagoNostr!.clearSession()
await vi.advanceTimersByTimeAsync(1600)
expect(fetchMock).not.toHaveBeenCalled()
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
const selection = providerWindow.archipelagoNostr!.selectIdentity()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin)
choose(frame, signerOrigin, key)
await expect(selection).resolves.toEqual({ nostr_pubkey: key })
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledOnce()
})
it('rejects a session response bound to another key after valid signing', async () => {
const key = 'a'.repeat(64)
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true, json: async () => ({ accessToken: token('b'.repeat(64)) }) })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('does not reset an in-flight auth when the same identity is announced again', async () => {
const key = 'a'.repeat(64)
let complete!: (response: unknown) => void
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
const notify = vi.fn()
window.addEventListener('archipelago:identity-changing', notify)
choose(frame, signerOrigin, key)
expect(notify).not.toHaveBeenCalled()
complete({ ok: true, json: async () => ({ accessToken: token(key) }) })
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe(token(key))
})
})
@@ -0,0 +1,43 @@
"""Offline behavior checks for the native post-install proxy prefix hook."""
import pathlib
import subprocess
import tempfile
import unittest
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[2]
def prefix_hook():
hooks = yaml.safe_load((ROOT / "apps/indeedhub/manifest.yml").read_text())["app"]["hooks"]["post_install"]
matches = [index for index, hook in enumerate(hooks) if "X-Forwarded-Prefix" in " ".join(hook.get("exec", []))]
assert len(matches) == 1
index = matches[0]
assert hooks[index + 1] == {"exec": ["nginx", "-s", "reload"]}
return hooks[index]["exec"]
class IndeeHubPrefixHook(unittest.TestCase):
def test_composes_prefix_before_reload_and_is_idempotent(self):
original = "server {\n proxy_set_header Host $http_host;\n proxy_set_header X-Forwarded-Prefix /api;\n}\n"
expected = original.replace("X-Forwarded-Prefix /api;", "X-Forwarded-Prefix $http_x_forwarded_prefix/api;")
with tempfile.TemporaryDirectory() as directory:
config = pathlib.Path(directory) / "default.conf"
config.write_text(original)
args = prefix_hook()
self.assertEqual(args[-1], "/etc/nginx/conf.d/default.conf")
for _ in range(2):
subprocess.run([*args[:-1], str(config)], check=True)
self.assertEqual(config.read_text(), expected)
def test_existing_composed_prefix_is_preserved(self):
original = "proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;\n"
with tempfile.TemporaryDirectory() as directory:
config = pathlib.Path(directory) / "default.conf"
config.write_text(original)
subprocess.run([*prefix_hook()[:-1], str(config)], check=True)
self.assertEqual(config.read_text(), original)
if __name__ == "__main__":
unittest.main()