Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
+68 -1
View File
@@ -14,7 +14,7 @@
var providerScript = document.currentScript;
var autoNip98 = !(providerScript && providerScript.hasAttribute('data-no-nip98'));
var embedded = window !== window.top;
var pending = {}, nextId = 1, queuedMessages = [];
var pending = {}, rentalPending = {}, mediaPending = {}, nextId = 1, queuedMessages = [];
var identitySelection = null;
var selectedIdentity = null, identitySubscribers = [];
var selectedPublicKey = null, selectedPublicKeyTimer = null;
@@ -288,6 +288,21 @@
cancelIdentitySelection();
return;
}
if (e.data.type === 'archipelago-rental-response') {
var rental = rentalPending[e.data.id];
if (!rental) return;
delete rentalPending[e.data.id]; clearTimeout(rental.timer);
e.data.error ? rental.reject(new Error(e.data.error)) : rental.resolve(e.data.result);
return;
}
if (e.data.type === 'archipelago-media-registration-response') {
var media = mediaPending[e.data.id];
if (!media) return;
delete mediaPending[e.data.id];
clearTimeout(media.timer);
e.data.error ? media.reject(new Error(e.data.error)) : media.resolve(e.data.result);
return;
}
if (e.data.type !== 'nostr-response') return;
var handler = pending[e.data.id];
if (!handler) return;
@@ -310,6 +325,58 @@
},
};
// Exact owner-approved Cloud registration. The dashboard checks the installed
// app origin/audience and displays the native picker before any preparation.
function nativeRequestId() {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
// Secure randomness remains available on ordinary HTTP node/LAN origins.
var bytes = new Uint8Array(16); crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
var hex = Array.from(bytes, function (value) { return value.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);
}
window.archipelagoRental = {
status: function (handle) {
return new Promise(function (resolve, reject) {
var id = nativeRequestId();
rentalPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () {
delete rentalPending[id]; reject(new Error('Playback status unavailable.'));
}, 15000) };
postToSigner({ type: 'archipelago-rental-request', id: id, action: 'status', handle: handle });
});
},
request: function (offer) {
return new Promise(function (resolve, reject) {
var id = nativeRequestId();
rentalPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () {
delete rentalPending[id]; reject(new Error('Rental response unavailable. Reopen this title to recover the same purchase.'));
}, 600000) };
postToSigner({ type: 'archipelago-rental-request', id: id, offer: offer });
});
}
};
window.archipelagoMediaRegistration = {
request: function (action, payload) {
if (['select', 'resume', 'submit', 'complete', 'resolve', 'resolve-submit'].indexOf(action) === -1 || !payload || typeof payload !== 'object') {
return Promise.reject(new Error('Invalid native media registration request'));
}
return new Promise(function (resolve, reject) {
var id = nativeRequestId();
mediaPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () {
var item = mediaPending[id];
if (!item) return;
delete mediaPending[id];
item.reject(new Error('Registration was not confirmed. Resume the same saved operation.'));
}, 600000) };
postToSigner({ type: 'archipelago-media-registration-request', id: id, action: action,
intent: payload.intent, selection: payload.selection, approvalId: payload.approvalId,
producerEvent: payload.producerEvent });
});
},
};
window.archipelagoNostr = {
selectIdentity: selectIdentity,
onIdentitySelected: onIdentitySelected,
@@ -181,6 +181,10 @@
</div>
</Transition>
<RentalPurchaseConsent v-if="!store.showConsent && !showIdentityPicker && !store.registrationRequest" :request="store.rentalRequest" :quote="store.rentalQuote" :phase="store.rentalPhase" :error="store.rentalError" @cancel-unpaid="store.cancelUnpaidRental" @review="store.reviewRental" @approve="store.approveRental" @cancel="store.cancelRental" />
<MediaRegistrationConsent v-if="!store.showConsent"
:request="store.registrationRequest" :phase="store.registrationPhase" :error="store.registrationError"
@approve="store.approveRegistration" @resolve="store.approveRegistrationResolution" @cancel="store.cancelRegistration" />
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
@@ -211,6 +215,8 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
@@ -275,6 +281,7 @@ watch(iframeLoading, (loading) => {
// Nostr identity picker state
const showIdentityPicker = ref(false)
watch(showIdentityPicker, value => store.setNativeIdentityBusy(value), { flush: 'sync' })
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
@@ -0,0 +1,71 @@
<template>
<div v-if="request" class="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6">
<section ref="modal" role="dialog" aria-modal="true" :aria-labelledby="titleId"
:aria-busy="loading || phase === 'preparing'" class="glass-card flex max-h-[90%] w-full max-w-xl flex-col overflow-hidden rounded-2xl p-4 sm:p-6">
<header class="flex items-start gap-3">
<div class="min-w-0 flex-1"><p class="text-xs text-white/50">IndeeHub · Cloud video</p>
<h2 :id="titleId" class="mt-1 text-xl font-semibold text-white">{{ phase === 'resolve' ? 'Recover your video registration' : phase === 'select' ? 'Choose your project video' : phase === 'signing' ? 'Approve your producer signature' : 'Preparing your video' }}</h2></div>
<button type="button" aria-label="Close video registration" class="min-h-11 min-w-11 rounded-lg text-white/70 hover:bg-white/10" @click="emit('cancel')">✕</button>
</header>
<dl class="my-4 grid grid-cols-2 gap-3 rounded-xl border border-white/10 p-3 text-sm">
<div class="col-span-2 min-w-0"><dt class="text-white/45">Project</dt><dd class="break-words text-white">{{ request.intent.projectId }}</dd></div>
<div><dt class="text-white/45">Price</dt><dd class="text-white">{{ request.intent.priceSats }} sats</dd></div>
<div><dt class="text-white/45">Access after first play</dt><dd class="text-white">{{ duration }}</dd></div>
</dl>
<p v-if="error || localError" role="alert" class="mb-3 rounded-lg border border-red-400/30 p-3 text-sm text-red-200">{{ error || localError }}</p>
<div v-if="phase === 'resolve'" class="space-y-4 py-3 text-sm text-white/70">
<p>The node will recover this request's completed video and receipt. If it expired before completion, the node will retire the request so you can start another.</p>
<button type="button" class="glass-button min-h-11 w-full rounded-lg border-orange-400/30 px-4 py-3 text-orange-200" @click="emit('resolve')">Recover or retire expired request</button>
</div>
<template v-else-if="phase === 'select'">
<div class="mb-2 flex items-center gap-2"><button type="button" :disabled="directory === '/' || loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75 disabled:opacity-40" @click="up">Up</button><p class="min-w-0 flex-1 break-all text-xs text-white/50">Cloud{{ directory }}</p><button type="button" :disabled="loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75" @click="load(directory)">Refresh</button></div>
<div class="min-h-24 overflow-y-auto rounded-xl border border-white/10">
<p v-if="loading" role="status" class="p-4 text-sm text-white/55">Loading Cloud files…</p>
<p v-else-if="!visible.length" class="p-4 text-sm text-white/55">No supported videos here. Choose an MP4, WebM or MOV file.</p>
<button v-for="file in visible" :key="file.path" type="button" :disabled="loading"
:aria-pressed="!file.isDir && selected?.path === file.path" class="flex min-h-12 w-full items-center gap-3 border-b border-white/5 px-3 py-2 text-left text-sm hover:bg-white/10"
:class="selected?.path === file.path ? 'bg-orange-400/15 text-orange-200' : 'text-white/80'" @click="choose(file)">
<span aria-hidden="true">{{ file.isDir ? '▸' : '▷' }}</span><span class="min-w-0 flex-1 break-words">{{ file.name }}</span>
<span v-if="!file.isDir" class="shrink-0 text-xs text-white/40">{{ formatSize(file.size) }}</span>
</button>
</div>
<p class="my-3 text-xs leading-relaxed text-white/50">The node keeps a fixed copy for this project's rental terms. This step prepares the video; publishing remains a separate action.</p>
<footer class="mt-auto flex flex-col-reverse gap-2 pt-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="emit('cancel')">Cancel</button><button type="button" :disabled="!selected || loading" class="glass-button min-h-11 flex-1 rounded-lg border-orange-400/30 px-4 text-orange-200 disabled:opacity-40" @click="approve">Use this video</button></footer>
</template>
<div v-else role="status" class="py-5 text-sm leading-relaxed text-white/65">
<p class="break-all">{{ request.selection?.relative_path }}</p>
<p class="mt-3">{{ request.resolution ? (phase === 'signing' ? 'Sign the request to recover its original result or retire it if it expired before completion.' : 'Checking the original operation. Keep this request until its result is confirmed.') : (phase === 'signing' ? 'Sign the exact project, video and terms with your active producer identity. Your signing key stays in its signer.' : 'Creating the fixed video copy and durable registration. You can reopen the same operation if the connection is interrupted.') }}</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RegistrationRequest, CloudSelection } from '@/composables/useMediaRegistrationBridge'
const props = defineProps<{ request: RegistrationRequest | null; phase: 'select' | 'resolve' | 'signing' | 'preparing'; error: string }>()
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()}`
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` })
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
async function load(path: string) {
const run = ++generation; loading.value = true; localError.value = ''; selected.value = null
try {
if (!await fileBrowserClient.login()) throw new Error('Cloud files could not be opened. Check your node connection and try again.')
const result = await fileBrowserClient.listDirectory(path)
if (run !== generation) return
files.value = result; directory.value = path
} catch (error) { if (run === generation) localError.value = error instanceof Error ? error.message : 'Cloud files could not be loaded.' }
finally { if (run === generation) loading.value = false }
}
function up() { const pieces = directory.value.split('/').filter(Boolean); pieces.pop(); void load('/' + pieces.join('/')) }
function choose(file: FileBrowserItem) { if (file.isDir) void load(file.path); else selected.value = file }
function approve() { if (!selected.value) return; emit('approve', { relative_path: selected.value.path.replace(/^\/+/, ''), payment_methods: ['cashu'] }) }
function formatSize(bytes: number) { return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${(bytes / 1024 ** 2).toFixed(1)} MB` }
watch(() => props.request?.intent.requestId, id => { ++generation; files.value = []; selected.value = null; localError.value = ''; if (id && props.phase === 'select') void load('/'); else loading.value = false }, { immediate: true })
</script>
+18 -2
View File
@@ -28,7 +28,12 @@
<div v-else class="mb-5 space-y-2">
<div class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Request</p><p class="text-sm font-medium text-white">{{ methodLabel }}</p></div>
<div v-if="identityLabel" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Identity</p><p class="text-sm font-medium text-white">{{ identityLabel }}</p></div>
<div v-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="mediaApproval" class="space-y-2 rounded-xl border border-white/10 bg-black/20 p-3 text-sm">
<p class="text-white/75">{{ mediaApproval.resolving ? 'Recover the completed registration or retire its expired incomplete request.' : 'Register this video for the selected IndeeHub project.' }}</p>
<dl class="space-y-2"><div><dt class="text-xs text-white/45">Project</dt><dd class="break-all text-white">{{ mediaApproval.project }}</dd></div><div v-if="mediaApproval.file"><dt class="text-xs text-white/45">Cloud video</dt><dd class="break-all text-white">{{ mediaApproval.file }}</dd></div><div><dt class="text-xs text-white/45">Rental terms</dt><dd class="text-white">{{ mediaApproval.price }} sats · {{ mediaApproval.seconds }} seconds after first play</dd></div></dl>
<details><summary class="min-h-11 cursor-pointer py-3 text-white/65">Full signed terms</summary><pre class="max-h-48 overflow-auto whitespace-pre-wrap break-all text-xs text-white/60">{{ content }}</pre></details>
</div>
<div v-else-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="eventKind !== undefined" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Event kind</p><p class="text-sm font-medium text-white">{{ eventKind }} <span class="text-white/45">({{ eventKindLabel }})</span></p></div>
</div>
@@ -55,7 +60,7 @@ const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata', 1: 'Short text note', 2: 'Recommend relay', 3: 'Contacts', 4: 'Encrypted DM',
5: 'Event deletion', 6: 'Repost', 7: 'Reaction', 1618: 'Git pull request', 1619: 'Git pull request update',
1621: 'Git issue', 9734: 'Zap request', 9735: 'Zap receipt', 10002: 'Relay list',
30023: 'Long-form content', 30617: 'Git repository announcement',
27236: 'Local Cloud video registration', 27237: 'Recover or retire video registration', 30023: 'Long-form content', 30617: 'Git repository announcement',
}
const METHOD_LABELS: Record<string, string> = {
getPublicKey: 'Share public identity', signEvent: 'Sign Nostr event',
@@ -78,6 +83,17 @@ const methodLabel = computed(() => METHOD_LABELS[props.method] ?? props.method)
const requestTitle = computed(() => props.method === 'getPublicKey' ? 'Share this identity?' : 'Approve this request?')
const progressTitle = computed(() => props.method === 'getPublicKey' ? 'Sharing identity…' : 'Signing locally…')
const successTitle = computed(() => props.method === 'getPublicKey' ? 'Identity shared' : 'Request signed')
const mediaApproval = computed(() => {
if (![27236, 27237].includes(props.eventKind ?? 0) || !props.content) return null
try {
const value = JSON.parse(props.content)
if (!['archipelago.media-registration.approval.v1', 'archipelago.media-registration.resolution.v1'].includes(value.scope)
|| typeof value.intent?.projectId !== 'string' || (props.eventKind === 27236 && typeof value.selection?.cloudFile !== 'string')
|| !Number.isSafeInteger(value.intent.priceSats) || !Number.isSafeInteger(value.intent.viewingSeconds)) return null
return { project: value.intent.projectId, file: value.selection?.cloudFile ?? '', resolving: props.eventKind === 27237,
price: value.intent.priceSats, seconds: value.intent.viewingSeconds }
} catch { return null }
})
const contentPreview = computed(() => !props.content ? '' : props.content.length > 200 ? `${props.content.slice(0, 200)}…` : props.content)
const eventKindLabel = computed(() => props.eventKind === undefined ? '' : EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown')
function approve() { emit('approve', rememberChoice.value) }
@@ -0,0 +1,25 @@
<template>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after first play</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RentalOffer } from '@/composables/useRentalPurchaseBridge'
const props = defineProps<{ request: RentalOffer | null; quote: { wallet_debit_sats: number; network: 'mainnet' | 'testnet'; mint_url: string } | null; phase: string; error: string }>()
const emit = defineEmits<{ review: []; approve: []; cancel: []; cancelUnpaid: [] }>()
const modal = ref<HTMLElement | null>(null)
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
const busy = computed(() => props.phase === 'loading' || props.phase === 'paying')
</script>
@@ -5,7 +5,7 @@ import AppLauncherOverlay from '../AppLauncherOverlay.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
vi.mock('@/stores/appLauncher', async () => {
const { reactive } = await import('vue')
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review' })
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review', setNativeIdentityBusy: vi.fn(), registrationRequest: null, registrationPhase: 'select', registrationError: '', rentalRequest: null, rentalQuote: null, rentalPhase: 'review', rentalError: '' })
return { useAppLauncherStore: () => state }
})
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
const source=readFileSync('public/nostr-provider.js','utf8')
describe('native provider on ordinary HTTP node origins',()=>{
it('uses secure randomness without randomUUID for both rental and registration requests',async()=>{
const listeners:((event:unknown)=>void)[]=[]
const parent={postMessage:vi.fn()}
const window:any={top:{},parent,location:{href:'http://node.local:7778/browse',pathname:'/browse',port:'7778',origin:'http://node.local:7778'},addEventListener:(_name:string,handler:(event:unknown)=>void)=>listeners.push(handler)}
const timers=new Set<unknown>();let count=0
runInNewContext(source,{window,document:{currentScript:{hasAttribute:()=>true},readyState:'complete'},crypto:{getRandomValues:(bytes:Uint8Array)=>{bytes.fill(++count);return bytes}},Uint8Array,URL,console,
setTimeout:(fn:unknown)=>{timers.add(fn);return fn},clearTimeout:(fn:unknown)=>timers.delete(fn)})
const rental=window.archipelagoRental.request({title:'Film'})
const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}})
const requests=parent.postMessage.mock.calls.map(([message])=>message)
expect(requests).toHaveLength(2)
expect(requests[0].id).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/)
expect(requests[1].id).not.toBe(requests[0].id)
for(const message of requests) for(const receive of listeners) receive({source:parent,origin:'http://node.local',data:{type:message.type.replace('-request','-response'),id:message.id,result:{ok:true}}})
await expect(rental).resolves.toEqual({ok:true});await expect(registration).resolves.toEqual({ok:true})
expect(timers.size).toBe(0)
})
})
@@ -0,0 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { keepCashuAttempt, parseCashuQuote, readCashuAttempt } from '../peerCashuPurchase'
const quote = { state: 'confirmation_required' as const, network: 'testnet' as const, mint_url: 'https://original.example.test/mint', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 }
beforeEach(() => localStorage.clear())
describe('saved Cashu quote identity', () => {
it('retains original network and mint across browser recovery and rejects substitution', () => {
keepCashuAttempt('peer','file',quote,false)
expect(readCashuAttempt('peer','file')?.quote).toEqual(quote)
expect(() => keepCashuAttempt('peer','file',{...quote,network:'mainnet'},false)).toThrow('original purchase')
expect(() => keepCashuAttempt('peer','file',{...quote,mint_url:'https://replacement.test'},false)).toThrow('original purchase')
expect(readCashuAttempt('peer','file')?.quote).toEqual(quote)
})
it.each([{network:undefined},{network:'unknown'},{mint_url:undefined},{mint_url:'javascript:bad'},{mint_url:'https://user:secret@mint.test'}])('refuses an incomplete or unsafe identity %j', invalid => {
expect(() => parseCashuQuote({...quote,...invalid})).toThrow('invalid payment quote')
})
})
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc }))
import { installedOriginMatches, useMediaRegistrationBridge } from '../useMediaRegistrationBridge'
const intent = { version: 1 as const, requestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', nonce: 'a'.repeat(64),
appAudience: 'fixture-installed-app', nodeDid: 'did:key:fixture', producer: 'b'.repeat(64), projectId: 'project',
priceSats: 15, viewingSeconds: 3600, createdAt: 1000, expiresAt: 1600 }
const selection = { relative_path: 'Movies/film.mp4', payment_methods: ['cashu'] }
const installed = { appId: 'indeedhub', appAudience: intent.appAudience, nodeDid: intent.nodeDid, appOrigins: ['https://node.test:7778'] }
let sequence = 1
const id = () => `bbbbbbbb-bbbb-4bbb-8bbb-${String(sequence++).padStart(12, '0')}`
function fixture() {
const child = { postMessage: vi.fn() }
const bridge = useMediaRegistrationBridge({ appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window })
const send = (action: string, extra: Record<string, unknown> = {}, origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({
data: { type: 'archipelago-media-registration-request', id: id(), action, intent, ...extra }, origin, source,
} as MessageEvent)
return { child, bridge, send }
}
beforeEach(() => {
localStorage.clear(); vi.clearAllMocks(); sequence = 1
vi.spyOn(Date, 'now').mockReturnValue(1100_000)
vi.stubGlobal('crypto', { randomUUID: id })
rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : { requestId: intent.requestId, contentId: 'registered_fixture' })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('native Cloud registration ownership and interrupted operation boundary', () => {
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, {})
expect(rpc.call).not.toHaveBeenCalled(); expect(f.bridge.request.value).toBeNull()
rpc.call.mockResolvedValue({ ...installed, appAudience: 'other-install' })
await f.send('select')
expect(f.bridge.request.value).toBeNull()
expect(f.child.postMessage.mock.lastCall?.[0].error).toContain('installed IndeeHub')
})
it('saves exact owner approval before signature and never prepares changed file/terms', async () => {
const f = fixture(); await f.send('select')
expect(localStorage.length).toBe(0)
f.bridge.approve(selection)
const approved = f.child.postMessage.mock.lastCall![0].result
expect(localStorage.length).toBe(1)
expect(f.bridge.phase.value).toBe('signing')
await f.send('submit', { selection: { ...selection, relative_path: 'private.mp4' }, approvalId: approved.approvalId })
expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0)
await f.send('submit', { selection, approvalId: approved.approvalId, producerEvent: { ...approved.event, pubkey: intent.producer, content: '{}' } })
expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0)
})
it('resumes the exact saved event after reload and expiry without a new selection or timestamp', async () => {
const first = fixture(); await first.send('select'); first.bridge.approve(selection)
const original = first.child.postMessage.mock.lastCall![0].result
first.bridge.dispose()
vi.mocked(Date.now).mockReturnValue(1700_000)
const resumed = fixture(); await resumed.send('resume')
expect(resumed.child.postMessage.mock.lastCall![0].result).toEqual(original)
const signed = { ...original.event, pubkey: intent.producer, id: 'c'.repeat(64), sig: 'd'.repeat(128) }
await resumed.send('submit', { selection, approvalId: original.approvalId, producerEvent: signed })
expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(1)
expect(resumed.bridge.request.value).toBeNull()
await resumed.send('complete')
expect(localStorage.length).toBe(0)
await resumed.send('complete')
expect(resumed.child.postMessage.mock.lastCall![0].result.completed).toBe(true)
})
it('allows same-operation signer cancellation retry but rejects replacement pending terms', async () => {
const f = fixture(); await f.send('select'); f.bridge.approve(selection)
const original = f.child.postMessage.mock.lastCall![0].result
await f.send('resume')
expect(f.child.postMessage.mock.lastCall![0].result).toEqual(original)
await f.send('resume', { intent: { ...intent, priceSats: 99 } })
expect(f.child.postMessage.mock.lastCall![0].error).toBeTruthy()
expect(f.bridge.request.value!.intent.priceSats).toBe(15)
})
it('does not authorize preparation when owner approval cannot be persisted', async () => {
const f = fixture(); await f.send('select')
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage full') })
f.bridge.approve(selection)
expect(f.bridge.phase.value).toBe('select')
expect(f.bridge.error.value).toBe('storage full')
expect(f.child.postMessage).not.toHaveBeenCalled()
})
it('reserves validation and cannot restore a cancelled selection after its response arrives', async () => {
const f=fixture(); let release!:(value:unknown)=>void
const implementation=rpc.call.getMockImplementation()!
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
const work=f.send('select')
expect(f.bridge.isBusy()).toBe(true)
f.bridge.cancel()
release(await implementation({method:'media.registration.context'})); await work
expect(f.bridge.request.value).toBeNull();expect(f.bridge.isBusy()).toBe(false)
})
it('recovers resolution approval across reload and cannot use it to prepare a file', async () => {
vi.mocked(Date.now).mockReturnValue(1700_000)
const first = fixture(); await first.send('resolve')
expect(first.bridge.phase.value).toBe('resolve')
first.bridge.approveResolution()
const original = first.child.postMessage.mock.lastCall![0].result
expect(original.event.kind).toBe(27237)
first.bridge.dispose()
vi.mocked(Date.now).mockReturnValue(1900_000)
const next = fixture(); await next.send('resolve')
expect(next.child.postMessage.mock.lastCall![0].result).toEqual(original)
const signed = { ...original.event, pubkey: intent.producer, id: 'c'.repeat(64), sig: 'd'.repeat(128) }
await next.send('submit', { selection, approvalId: original.approvalId, producerEvent: signed })
expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0)
await next.send('resolve-submit', { approvalId: original.approvalId, producerEvent: signed })
expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.resolve')).toHaveLength(1)
await next.send('complete')
expect(localStorage.length).toBe(0)
})
})
describe('installed registration origin mapping',()=>{
it('keeps mapped loopback origins on the actual dashboard hostname and configured port',()=>{
const actual=new URL(window.location.href);actual.port='7778'
expect(installedOriginMatches(actual.origin,`${actual.protocol}//127.0.0.1:7778`)).toBe(true)
expect(installedOriginMatches('http://foreign.test:7778','http://127.0.0.1:7778')).toBe(false)
expect(installedOriginMatches(actual.origin,`${actual.protocol}//127.0.0.1:7779`)).toBe(false)
})
})
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc }))
import { supportsRentalPlaybackOrigin, useRentalPurchaseBridge } from '../useRentalPurchaseBridge'
const offer = { title: 'Film', terms: { nodeDid: 'did:key:fixture', contentId: 'registered_fixture', sha256: 'a'.repeat(64), priceSats: 8, viewingSeconds: 3600 } }
const installed = { appId: 'indeedhub', appOrigins: ['https://node.test:7778'] }
const quote = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', envelope_sha256: 'b'.repeat(64), wallet_debit_sats: 10, gross_token_sats: 9, seller_net_sats: 8, expires_at: 2000, seller_onion: 'fixture.onion' }
function fixture(consentBusy: () => boolean = () => false) {
const child = { postMessage: vi.fn() }
const bridge = useRentalPurchaseBridge({ consentBusy, appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window })
const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', offer }, origin, source } as MessageEvent)
return { bridge, child, send }
}
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => { vi.stubGlobal('location', new URL('https://node.test')); vi.clearAllMocks(); rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : method === 'content.rental-purchase' ? quote : { playback_url: '/api/rental-playback/' + 'd'.repeat(64), expires_at: null }) })
describe('native rental confirmation', () => {
it('cannot approve a quote without its saved network and mint', async()=>{
const f=fixture();await f.send()
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{...quote,mint_url:undefined})
await f.bridge.review();expect(f.bridge.quote.value).toBeNull();expect(f.bridge.error.value).toContain('Invalid payment confirmation')
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
})
it('ignores foreign frames and origins before RPC', async () => { const f=fixture(); await f.send('https://foreign.test'); await f.send(undefined, {}); expect(rpc.call).not.toHaveBeenCalled() })
it('requires review then confirmation of the exact debit', async () => {
const f=fixture(); await f.send(); await f.bridge.approve(); expect(rpc.call).toHaveBeenCalledTimes(1)
await f.bridge.review(); expect(f.bridge.phase.value).toBe('confirm')
expect(rpc.call.mock.calls.find(([v]) => v.method==='content.rental-purchase')![0].params.consent).toBeUndefined()
rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{playback_url:'/api/rental-playback/'+'d'.repeat(64),expires_at:null})
await f.bridge.approve()
const calls=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase')
expect(calls[1]![0].params.consent).toEqual({operation_id:quote.operation_id,envelope_sha256:quote.envelope_sha256,wallet_debit_sats:10})
expect(calls[1]![0].params.max_wallet_debit).toBe(10); expect(f.bridge.request.value).toBeNull()
expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toContain('/api/rental-playback/')
})
it('does not dispatch after closing during installation validation', async()=>{
const f=fixture(); await f.send(); let release!:(value:unknown)=>void
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
const work=f.bridge.review();f.bridge.cancel();release(installed);await work
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false)
})
it('does not assign a delayed payment result to a replacement request', async()=>{
const f=fixture();await f.send();await f.bridge.review();let release!:(value:unknown)=>void
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve}))
const work=f.bridge.approve();await vi.waitFor(()=>expect(release).toBeTypeOf('function'))
f.bridge.cancel();await f.send();release({state:'entitled',operation_id:quote.operation_id});await work
expect(f.bridge.phase.value).toBe('review');expect(f.bridge.request.value).toEqual(offer)
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-handle')).toBe(false)
})
it('retries recovery without reusing UI approval after an ambiguous response', async()=>{
const f=fixture();await f.send();await f.bridge.review()
rpc.call.mockImplementation(async({method})=>{if(method==='media.registration.context')return installed;throw Error('Response lost')})
await f.bridge.approve();expect(f.bridge.phase.value).toBe('review');await f.bridge.review()
expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase').slice(-1)[0]![0].params.consent).toBeUndefined()
})
it('clears an unpaid quote only after acknowledged cancellation', async()=>{
const f=fixture();await f.send();await f.bridge.review()
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'unknown'})
await f.bridge.cancelUnpaid();expect(f.bridge.quote.value?.operation_id).toBe(quote.operation_id)
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'cancelled_unspent'})
await f.bridge.cancelUnpaid();expect(f.bridge.quote.value).toBeNull()
expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.cancel-purchase').slice(-1)[0]![0].params).toEqual({onion:'fixture.onion',operation_id:quote.operation_id})
})
it('lease status does not open a purchase or confirm spending', async()=>{
const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{expires_at:null})
await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
expect(rpc.call.mock.calls.map(([v])=>v.method)).toEqual(['media.registration.context','content.playback-status'])
expect(f.child.postMessage.mock.lastCall![0].result).toEqual({expires_at:null})
expect(f.bridge.request.value).toBeNull()
})
it('rejects a rental during another native confirmation without contacting the wallet',async()=>{
const f=fixture(()=>true);await f.send();expect(rpc.call).not.toHaveBeenCalled()
expect(f.child.postMessage.mock.lastCall![0].error).toContain('other native confirmation')
expect(f.bridge.request.value).toBeNull()
})
it('does not approve a reviewed quote while a signer confirmation owns the surface',async()=>{
let busy=false;const f=fixture(()=>busy);await f.send();await f.bridge.review()
const calls=rpc.call.mock.calls.length;busy=true;await f.bridge.approve()
expect(rpc.call).toHaveBeenCalledTimes(calls);expect(f.bridge.phase.value).toBe('confirm')
expect(f.bridge.error.value).toContain('other native confirmation')
})
it('drops a status response after the surface closes even if its WindowProxy is reused',async()=>{
const f=fixture();let release!:(value:unknown)=>void
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve}))
const work=f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
await vi.waitFor(()=>expect(release).toBeTypeOf('function'))
f.bridge.cancel();release({expires_at:100});await work
expect(f.child.postMessage).not.toHaveBeenCalled()
})
it('does not dispatch when the active frame disappears during installation verification',async()=>{
const child={postMessage:vi.fn()};let active=true
const bridge=useRentalPurchaseBridge({appId:()=> 'indeedhub',appUrl:()=> 'https://node.test:7778/browse',frameWindow:()=>active?child as unknown as Window:null})
await bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:child} as unknown as MessageEvent)
let release!:(value:unknown)=>void;rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
const work=bridge.review();active=false;bridge.cancel();release(installed);await work
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false)
})
it('rejects cross-site rental before preparation or spending',async()=>{
vi.stubGlobal('location',new URL('https://dashboard.onion'))
const f=fixture();await f.send()
expect(rpc.call).not.toHaveBeenCalled();expect(f.bridge.request.value).toBeNull()
expect(f.child.postMessage.mock.lastCall![0].error).toContain('same LAN hostname')
})
it('permits same-host ports but rejects separate onions and mixed schemes',()=>{
expect(supportsRentalPlaybackOrigin('https://node.test:7778','https://node.test')).toBe(true)
expect(supportsRentalPlaybackOrigin('http://node.test:7778','http://node.test')).toBe(true)
expect(supportsRentalPlaybackOrigin('http://app.onion','http://dashboard.onion')).toBe(false)
expect(supportsRentalPlaybackOrigin('http://node.test:7778','https://node.test')).toBe(false)
})
})
@@ -0,0 +1,51 @@
/** Supplemental UI recovery marker; immutable terms and settlement live on the node. */
export type CashuQuote = { network: 'mainnet' | 'testnet'; mint_url: string; state: 'confirmation_required'; operation_id: string; envelope_sha256: string; gross_token_sats: number; seller_net_sats: number; wallet_debit_sats: number; expires_at: number }
export type CashuAttempt = { version: 1; peer: string; contentId: string; quote: CashuQuote; dispatched: boolean }
export function validCashuIdentity(value: { network?: unknown; mint_url?: unknown }): boolean {
if (value.network !== 'mainnet' && value.network !== 'testnet') return false
if (typeof value.mint_url !== 'string' || value.mint_url.length > 2048) return false
try { const url = new URL(value.mint_url); return ['http:', 'https:'].includes(url.protocol) && Boolean(url.hostname) && !url.username && !url.password && !url.hash } catch { return false }
}
export function parseCashuQuote(value: unknown): CashuQuote {
const v = value as Partial<CashuQuote> | null
if (!v || !validCashuIdentity(v) || v.state !== 'confirmation_required' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v.operation_id || '')
|| !/^[0-9a-f]{64}$/.test(v.envelope_sha256 || '')
|| ![v.gross_token_sats, v.seller_net_sats, v.wallet_debit_sats, v.expires_at].every(n => Number.isSafeInteger(n) && Number(n) > 0)
|| v.wallet_debit_sats! < v.gross_token_sats! || v.gross_token_sats! < v.seller_net_sats!) throw new Error('The node returned an invalid payment quote. No new payment was confirmed.')
return v as CashuQuote
}
export function cashuAttemptKey(peer: string, id: string) { return `peer-file-cashu:${encodeURIComponent(peer)}:${encodeURIComponent(id)}` }
export function readCashuAttempt(peer: string, id: string): CashuAttempt | null {
const raw = localStorage.getItem(cashuAttemptKey(peer, id)); if (!raw) return null
const v = JSON.parse(raw) as CashuAttempt
if (v.version !== 1 || v.peer !== peer || v.contentId !== id || typeof v.dispatched !== 'boolean') throw new Error('Saved Cashu purchase needs recovery; do not pay again.')
parseCashuQuote(v.quote); return v
}
export function keepCashuAttempt(peer: string, id: string, quote: CashuQuote, dispatched: boolean) {
parseCashuQuote(quote)
const old = readCashuAttempt(peer, id)
if (old && (old.quote.operation_id !== quote.operation_id || old.quote.envelope_sha256 !== quote.envelope_sha256 || old.quote.wallet_debit_sats !== quote.wallet_debit_sats || old.quote.network !== quote.network || old.quote.mint_url !== quote.mint_url)) throw new Error('Recover or cancel the original purchase before replacing it.')
localStorage.setItem(cashuAttemptKey(peer, id), JSON.stringify({ version: 1, peer, contentId: id, quote, dispatched }))
}
export function clearCashuAttempt(peer: string, id: string) { localStorage.removeItem(cashuAttemptKey(peer, id)) }
/** Keep one bounded private browser copy before replacing an unreadable marker.
* The caller invokes this only after a valid node recovery response, never on
* network failure or to authorize fresh spending. */
export function archiveMalformedCashuAttempt(peer: string, id: string) {
try { readCashuAttempt(peer, id); return } catch { /* preserve before replacement */ }
const key = cashuAttemptKey(peer, id)
const raw = localStorage.getItem(key)
if (raw === null) return
if (new TextEncoder().encode(raw).byteLength > 65536) throw new Error('The saved recovery marker is too large to archive safely. It remains intact; no new payment was confirmed.')
const archiveKey = `${key}:unreadable`
const previous = localStorage.getItem(archiveKey)
if (previous !== null && previous !== raw) throw new Error('An earlier recovery marker is already archived. Original records remain intact; no new payment was confirmed.')
localStorage.setItem(archiveKey, raw)
localStorage.removeItem(key)
}
export function keepAuthoritativeCashuQuote(peer: string, id: string, quote: CashuQuote) {
parseCashuQuote(quote)
archiveMalformedCashuAttempt(peer, id)
keepCashuAttempt(peer, id, quote, false)
}
@@ -0,0 +1,225 @@
import { ref, shallowRef } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
export const REGISTRATION_SCOPE = 'archipelago.media-registration.approval.v1'
export interface RegistrationIntent {
version: 1; requestId: string; nonce: string; appAudience: string; nodeDid: string
producer: string; projectId: string; priceSats: number; viewingSeconds: number
createdAt: number; expiresAt: number
}
export interface CloudSelection { relative_path: string; payment_methods: string[] }
export interface RegistrationRequest { intent: RegistrationIntent; selection?: CloudSelection; resolution?: boolean }
interface SavedApproval { version: 1; intent: RegistrationIntent; selection: CloudSelection; approvalId: string; event: Record<string, unknown> }
const approvalKey = (intent: RegistrationIntent) => `archipelago:media-approval:${intent.requestId}`
function savedApproval(intent: RegistrationIntent): SavedApproval | null {
const raw = localStorage.getItem(approvalKey(intent))
if (raw === null) return null
if (raw.length > 32768) throw new Error('Saved Cloud approval is damaged. Preserve this operation for recovery.')
const saved = JSON.parse(raw) as SavedApproval
if (saved.version !== 1 || !exact(saved.intent, intent) || !saved.selection || !saved.approvalId || !saved.event) {
throw new Error('Saved Cloud approval differs from this operation. It has not been replaced.')
}
return saved
}
interface InstallationContext { appId: string; appAudience: string; nodeDid: string; appOrigins: string[] }
interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean }
interface Pending { generation: number; mode?: 'resolve'; source: Window; origin: string; requestId: string; intent: RegistrationIntent; selection?: CloudSelection; approvalId?: string; approvedEvent?: Record<string, unknown> }
export function approvalContent(intent: RegistrationIntent, selection: CloudSelection) {
return { action: 'Register this Cloud video for an IndeeHub project', scope: REGISTRATION_SCOPE,
intent, selection: { cloudFile: selection.relative_path, paymentMethods: selection.payment_methods } }
}
function exact(left: unknown, right: unknown): boolean {
if (left === right) return true
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length && left.every((v, i) => exact(v, right[i]))
const a = left as Record<string, unknown>, b = right as Record<string, unknown>
return Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.prototype.hasOwnProperty.call(b, k) && exact(a[k], b[k]))
}
function validatedIntent(value: unknown): RegistrationIntent {
const intent = value as RegistrationIntent
if (!intent || intent.version !== 1 || !/^[0-9a-f-]{36}$/.test(intent.requestId)
|| !/^[0-9a-f]{64}$/.test(intent.producer) || !/^[0-9a-f]{64}$/.test(intent.nonce)
|| typeof intent.nodeDid !== 'string' || !intent.nodeDid.startsWith('did:key:')
|| typeof intent.appAudience !== 'string' || !intent.appAudience || intent.appAudience.length > 128
|| typeof intent.projectId !== 'string' || !intent.projectId || intent.projectId.length > 128
|| !Number.isSafeInteger(intent.priceSats) || intent.priceSats < 0
|| !Number.isSafeInteger(intent.viewingSeconds) || intent.viewingSeconds < 1
|| !Number.isSafeInteger(intent.createdAt) || !Number.isSafeInteger(intent.expiresAt)
|| intent.expiresAt <= intent.createdAt || intent.expiresAt - intent.createdAt > 600) throw new Error('Invalid node registration intent.')
return structuredClone(intent)
}
export function installedOriginMatches(actual: string, expected: string): boolean {
try {
const a = new URL(actual), e = new URL(expected)
if (a.origin === e.origin) return true
// Runtime interface scans may report loopback. Only map that exact configured
// scheme/port to the dashboard host, never to an arbitrary app-supplied host.
const sameNode = a.hostname === window.location.hostname
const host = ['localhost', '127.0.0.1', '[::1]'].includes(e.hostname) || a.hostname === e.hostname
const scheme = a.protocol === e.protocol || (a.protocol === 'https:' && e.protocol === 'http:'
&& window.location.protocol === 'https:' && appPortIsGateFronted('indeedhub', a.port))
return host && sameNode && scheme && a.port === e.port
} catch { return false }
}
export function useMediaRegistrationBridge(context: FrameContext) {
const request = shallowRef<RegistrationRequest | null>(null)
const phase = ref<'select' | 'resolve' | 'signing' | 'preparing'>('select')
const error = ref('')
let pending: Pending | null = null, disposed = false, generation = 0, validating = 0
function current(item: Pending): boolean {
if (disposed || item.generation !== generation || item.source !== context.frameWindow() || context.appId() !== 'indeedhub') return false
try { return new URL(context.appUrl(), window.location.origin).origin === item.origin } catch { return false }
}
async function validateInstallation(item: Pending) {
const installed = await rpcClient.call<InstallationContext>({ method: 'media.registration.context', params: {} })
if (!current(item)) throw new Error('The app frame changed. Resume from the current app.')
if (installed.appId !== 'indeedhub' || installed.appAudience !== item.intent.appAudience
|| installed.nodeDid !== item.intent.nodeDid || !Array.isArray(installed.appOrigins)
|| !installed.appOrigins.some(expected => installedOriginMatches(item.origin, expected))) {
throw new Error('This request does not match the installed IndeeHub identity and browser origin.')
}
}
function reply(item: Pending, result?: unknown, failure?: string) {
if (!current(item)) return
item.source.postMessage({ type: 'archipelago-media-registration-response', id: item.requestId,
...(failure ? { error: failure } : { result }) }, item.origin)
}
function cancel() {
if (pending) reply(pending, undefined, phase.value === 'preparing'
? 'Preparation may still be running. Resume this same registration.' : 'Cloud selection cancelled.')
generation++
pending = null; request.value = null; error.value = ''; phase.value = 'select'
}
function approve(selection: CloudSelection) {
if (!pending || phase.value !== 'select' || !current(pending)) return
if (!selection.relative_path || selection.relative_path.startsWith('/')
|| selection.relative_path.split('/').some(p => !p || p === '.' || p === '..')
|| !/\.(mp4|m4v|webm|mov)$/i.test(selection.relative_path)
|| !exact(selection.payment_methods, ['cashu'])) { error.value = 'Choose a supported Cloud video.'; return }
try {
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),
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.
localStorage.setItem(approvalKey(pending.intent), JSON.stringify(saved))
pending.selection = saved.selection; pending.approvalId = saved.approvalId; pending.approvedEvent = saved.event
request.value = { intent: pending.intent, selection: saved.selection }; phase.value = 'signing'
reply(pending, { selection: saved.selection, approvalId: saved.approvalId, event: saved.event })
} catch (cause) { error.value = cause instanceof Error ? cause.message : 'Cloud approval could not be saved.' }
}
function approveResolution() {
if (!pending || pending.mode !== 'resolve' || phase.value !== 'resolve' || !current(pending)) return
const approved = { intent: pending.intent, approvalId: crypto.randomUUID(), 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),
} }
try {
localStorage.setItem(approvalKey(pending.intent) + ':resolution', JSON.stringify(approved))
pending.approvalId = approved.approvalId; pending.approvedEvent = approved.event; phase.value = 'signing'
reply(pending, { approvalId: approved.approvalId, event: approved.event })
} catch (cause) { error.value = cause instanceof Error ? cause.message : 'Resolution approval could not be saved.' }
}
async function handle(event: MessageEvent) {
if (disposed || context.appId() !== 'indeedhub' || event.source !== context.frameWindow()) return
let origin: string
try { origin = new URL(context.appUrl(), window.location.origin).origin } catch { return }
if (event.origin !== origin || !event.data || event.data.type !== 'archipelago-media-registration-request') return
const source = event.source as Window
const id = event.data.id
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return
if (context.consentBusy?.()) { source.postMessage({ type: 'archipelago-media-registration-response', id, error: 'Finish the other native confirmation first.' }, origin); return }
let size = Infinity
try { size = JSON.stringify(event.data).length } catch { return }
if (size > 32768) return
const item: Pending = { generation, source, origin, requestId: id, intent: event.data.intent }
validating++
try {
if (event.data.action === 'complete') {
item.intent = validatedIntent(event.data.intent)
if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) {
throw new Error('Wait for this registration to finish before clearing its saved approval.')
}
// App sends this only after its authenticated backend confirms receipt
// consumption. Exact-scope removal is idempotent if its reply is lost.
savedApproval(item.intent)
localStorage.removeItem(approvalKey(item.intent))
localStorage.removeItem(approvalKey(item.intent) + ':resolution')
if (pending && exact(pending.intent, item.intent)) { pending = null; request.value = null; phase.value = 'select' }
reply(item, { completed: true, requestId: item.intent.requestId }); return
}
if (event.data.action === 'resolve') {
item.intent = validatedIntent(event.data.intent); item.mode = 'resolve'
if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) throw new Error('Wait for the current registration operation.')
await validateInstallation(item)
if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) throw new Error('Wait for the current registration operation.')
const raw = localStorage.getItem(approvalKey(item.intent) + ':resolution')
let saved: { intent: RegistrationIntent; approvalId: string; event: Record<string, unknown> } | null = null
if (raw !== null) {
if (raw.length > 32768) throw new Error('Saved resolution is damaged; preserve the operation.')
saved = JSON.parse(raw)
if (!saved || !exact(saved.intent, item.intent) || !saved.approvalId || !saved.event) throw new Error('Saved resolution changed the original intent.')
}
pending = item; request.value = { intent: item.intent, resolution: true }; error.value = ''
if (saved) {
item.approvalId = saved.approvalId; item.approvedEvent = saved.event; phase.value = 'signing'
reply(item, { approvalId: saved.approvalId, event: saved.event })
} else { phase.value = 'resolve' }
return
}
if (event.data.action === 'select' || event.data.action === 'resume') {
if (pending && (phase.value !== 'signing' || !exact(pending.intent, event.data.intent))) {
throw new Error('Finish or cancel the current Cloud registration first.')
}
item.intent = validatedIntent(event.data.intent)
const saved = savedApproval(item.intent)
if (!saved && event.data.action === 'resume') throw new Error('The original owner approval is unavailable. Do not replace this pending operation.')
if (!saved && item.intent.expiresAt <= Math.floor(Date.now() / 1000)) throw new Error('This registration intent expired. Refresh its terms before selecting a new video.')
// Verify the installer-owned scope before displaying any Cloud data.
// This matters for the standalone broker, whose app name is caller-supplied.
await validateInstallation(item)
if (pending && (phase.value !== 'signing' || !exact(pending.intent, event.data.intent))) {
throw new Error('Finish or cancel the current Cloud registration first.')
}
pending = item; error.value = ''
if (saved) {
item.selection = saved.selection; item.approvalId = saved.approvalId; item.approvedEvent = saved.event
request.value = { intent: item.intent, selection: saved.selection }; phase.value = 'signing'
reply(item, { selection: saved.selection, approvalId: saved.approvalId, event: saved.event })
} else { request.value = { intent: item.intent }; phase.value = 'select' }
return
}
const resolving = event.data.action === 'resolve-submit'
if ((!resolving && event.data.action !== 'submit') || !pending || phase.value !== 'signing'
|| resolving !== (pending.mode === 'resolve') || !current(pending) || event.data.approvalId !== pending.approvalId
|| !exact(event.data.intent, pending.intent) || (!resolving && !exact(event.data.selection, pending.selection))) {
throw new Error('Approve this exact Cloud file and project before registering it.')
}
const approved = pending
const signed = event.data.producerEvent
if (!signed || signed.pubkey !== approved.intent.producer
|| !exact({ kind: signed.kind, created_at: signed.created_at, tags: signed.tags, content: signed.content }, approved.approvedEvent)) {
throw new Error('The producer signature does not match the original owner-approved event.')
}
// The producer event is verified by the node. The host never claims that
// request-body identity alone is an authenticated producer.
phase.value = 'preparing'; error.value = ''; approved.requestId = id
const result = await rpcClient.call({ method: resolving ? 'media.registration.resolve' : 'media.registration.prepare', params: {
intent: approved.intent, ...(!resolving ? { selection: approved.selection } : {}), producerEvent: event.data.producerEvent,
}, timeout: 600_000 })
if (pending !== approved) return
reply(approved, result); pending = null; request.value = null; phase.value = 'select'
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'Registration was not confirmed. Resume the same operation.'
reply(item, undefined, message)
if (pending?.requestId === id) { error.value = message; phase.value = 'signing' }
} finally { validating-- }
}
function dispose() { cancel(); disposed = true }
return { isBusy: () => pending !== null || validating > 0, request, phase, error, handle, approve, approveResolution, cancel, dispose }
}
@@ -0,0 +1,126 @@
import { ref, shallowRef } from 'vue'
import { validCashuIdentity } from './peerCashuPurchase'
import { rpcClient } from '@/api/rpc-client'
import { installedOriginMatches } from './useMediaRegistrationBridge'
interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean }
export interface RentalOffer { title: string; terms: { nodeDid: string; contentId: string; sha256: string; priceSats: number; viewingSeconds: number } }
interface Quote { network: 'mainnet' | 'testnet'; mint_url: string; state: string; operation_id: string; envelope_sha256: string; wallet_debit_sats: number; gross_token_sats: number; seller_net_sats: number; expires_at: number; seller_onion: string }
interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; quote?: Quote }
export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin: string): boolean {
try {
const app = new URL(appOrigin), dashboard = new URL(dashboardOrigin)
// Host-only SameSite=Lax owner cookies support same-host app ports, but
// not an app on a separate onion/domain or a mixed-scheme frame.
return ['http:', 'https:'].includes(app.protocol) && app.protocol === dashboard.protocol
&& app.hostname === dashboard.hostname
} catch { return false }
}
export function useRentalPurchaseBridge(context: FrameContext) {
const request = shallowRef<RentalOffer | null>(null), quote = shallowRef<Quote | null>(null)
const phase = ref<'review' | 'loading' | 'confirm' | 'paying'>('review'), error = ref('')
let pending: Pending | null = null, disposed = false, generation = 0
const frameOrigin = () => { try { return new URL(context.appUrl(), window.location.origin).origin } catch { return '' } }
const current = (item: Pending) => !disposed && pending === item && context.appId() === 'indeedhub'
&& context.frameWindow() === item.source && frameOrigin() === item.origin
function reply(item: Pending, result?: unknown, failure?: string) {
if (current(item)) item.source.postMessage({ type: 'archipelago-rental-response', id: item.id,
...(failure ? { error: failure } : { result }) }, item.origin)
}
function cancel() {
if (pending) reply(pending, undefined, phase.value === 'paying'
? 'Payment may be processing. Reopen this title to recover the same purchase.' : 'Rental confirmation closed. No new payment was approved.')
generation++
pending = null; request.value = null; quote.value = null; error.value = ''; phase.value = 'review'
}
async function installed(item: Pending) {
const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} })
if (!current(item) || value.appId !== 'indeedhub' || !value.appOrigins.some(origin => installedOriginMatches(item.origin, origin))) throw new Error('The installed app or its frame changed.')
}
async function run(confirm: boolean) {
const item = pending
if (!item || !current(item) || (confirm ? phase.value !== 'confirm' : phase.value !== 'review')) return
if (!supportsRentalPlaybackOrigin(item.origin, window.location.origin)) { error.value = 'Open the app from the same node dashboard address before paying.'; return }
if (context.consentBusy?.()) { error.value = 'Finish the other native confirmation first.'; return }
phase.value = confirm ? 'paying' : 'loading'; error.value = ''
try {
await installed(item)
if (!current(item)) return
if (context.consentBusy?.()) throw new Error('Finish the other native confirmation first.')
const terms = item.offer.terms
const result = await rpcClient.call<Quote>({ method: 'content.rental-purchase', params: {
seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256,
expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds,
max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
...(confirm ? { consent: { operation_id: item.quote!.operation_id,
envelope_sha256: item.quote!.envelope_sha256, wallet_debit_sats: item.quote!.wallet_debit_sats } } : {}),
}, timeout: 120000 })
if (!current(item)) return
if (result.state === 'confirmation_required') {
if (!validCashuIdentity(result) || !Number.isSafeInteger(result.wallet_debit_sats) || result.wallet_debit_sats < terms.priceSats
|| !/^[0-9a-f]{64}$/.test(result.envelope_sha256) || !result.operation_id) throw new Error('Invalid payment confirmation. No payment approved.')
item.quote = result; quote.value = result; phase.value = 'confirm'; return
}
if (result.state !== 'entitled') throw new Error(result.state === 'cancelled_unspent' ? 'This purchase was cancelled without spending.' : 'Purchase has not completed. Reopen this title to recover it.')
const playback = await rpcClient.call<{ playback_url: string; expires_at: number | null }>({ method: 'content.playback-handle', params: { purchase_id: result.operation_id } })
if (!current(item)) return
if (!/^\/api\/rental-playback\/[0-9a-f]{64}$/.test(playback.playback_url)) throw new Error('Invalid playback address.')
reply(item, { ...playback, playback_url: new URL(playback.playback_url, window.location.origin).href, operation_id: result.operation_id })
pending = null; request.value = null; quote.value = null
} catch (cause) {
if (current(item)) { error.value = cause instanceof Error ? cause.message : 'Could not complete this purchase. Retry to recover its original result.'; phase.value = 'review' }
}
}
async function cancelUnpaid() {
const item = pending
if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading') return
phase.value = 'loading'; error.value = ''
try {
await installed(item)
if (!current(item)) return
const result = await rpcClient.call<{ state: string }>({ method: 'content.cancel-purchase',
params: { onion: item.quote.seller_onion, operation_id: item.quote.operation_id }, timeout: 120000 })
if (!current(item)) return
if (result.state !== 'cancelled_unspent') throw new Error('Cancellation has not been confirmed. Recover this original purchase before trying another payment.')
item.quote = undefined; quote.value = null; phase.value = 'review'
error.value = 'The unpaid quote was cancelled. Check purchase to request fresh terms.'
} catch (cause) { if (current(item)) { error.value = String(cause); phase.value = 'review' } }
}
async function handle(event: MessageEvent) {
if (disposed || context.appId() !== 'indeedhub' || event.source !== context.frameWindow()
|| event.origin !== frameOrigin() || event.data?.type !== 'archipelago-rental-request') return
const { id, offer } = event.data
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return
if (event.data.action === 'status') {
const source = event.source as Window, origin = event.origin, handle = event.data.handle, scope = generation
const selected = () => !disposed && generation === scope && context.appId() === 'indeedhub' && source === context.frameWindow() && frameOrigin() === origin
if (typeof handle !== 'string' || !/^[0-9a-f]{64}$/.test(handle)) return
try {
const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} })
if (!selected()) return
if (value.appId !== 'indeedhub' || !value.appOrigins.some(expected => installedOriginMatches(origin, expected))) throw new Error('Installed app changed.')
const result = await rpcClient.call<{ expires_at: number | null }>({ method: 'content.playback-status', params: { handle } })
if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, result }, origin)
} catch (cause) {
if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, error: String(cause) }, origin)
}
return
}
if (!supportsRentalPlaybackOrigin(event.origin, window.location.origin)) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Open IndeeHub from this node’s dashboard using the same LAN hostname and HTTP/HTTPS scheme before renting. This app address cannot receive the playback session cookie; no payment was requested.' }, event.origin); return }
if (context.consentBusy?.()) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Finish the other native confirmation first.' }, event.origin); return }
if (pending) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Finish or close the current rental confirmation first.' }, event.origin); return }
const terms = offer?.terms
if (!terms || typeof offer.title !== 'string' || offer.title.length > 240
|| typeof terms.nodeDid !== 'string' || !terms.nodeDid.startsWith('did:key:')
|| typeof terms.contentId !== 'string' || !/^registered_[a-zA-Z0-9_-]+$/.test(terms.contentId)
|| !/^[0-9a-f]{64}$/.test(terms.sha256) || !Number.isSafeInteger(terms.priceSats) || terms.priceSats < 0
|| !Number.isSafeInteger(terms.viewingSeconds) || terms.viewingSeconds < 1) {
(event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Invalid rental terms.' }, event.origin); return
}
const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer) }
pending = item
try { await installed(item); if (current(item)) { request.value = item.offer; phase.value = 'review' } }
catch (cause) { reply(item, undefined, String(cause)); if (pending === item) pending = null }
}
return { isBusy: () => pending !== null, request, quote, phase, error, handle, cancelUnpaid, review: () => run(false), approve: () => run(true), cancel,
dispose: () => { cancel(); disposed = true } }
}
+36 -5
View File
@@ -14,6 +14,8 @@ import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/us
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
/**
@@ -507,6 +509,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function close() {
bridge.cancelPending()
registrationBridge.cancel(); rentalBridge.cancel()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -514,7 +517,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
title.value = ''
// Explicitly remove NIP-07 listener as safety net — if user navigates away
// without close() triggering the isOpen watcher, the listener would leak
window.removeEventListener('message', handleNostrRequest)
window.removeEventListener('message', handleNativeRequest)
if (toRestore && typeof toRestore.focus === 'function') {
requestAnimationFrame(() => {
toRestore.focus()
@@ -533,6 +536,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return { ...stored, name: typeof stored.name === 'string' ? stored.name : stored.id }
} catch { return null }
}
const nativeIdentityBusy = ref(false)
const registrationBridge = useMediaRegistrationBridge({
consentBusy: (): boolean => rentalBridge.isBusy(),
appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app',
appUrl: () => url.value, frameWindow: () => isOpen.value ? nostrFrame : null,
})
const rentalBridge = useRentalPurchaseBridge({
consentBusy: (): boolean => bridge.showConsent.value || nativeIdentityBusy.value || registrationBridge.isBusy(),
appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app',
appUrl: () => url.value, frameWindow: () => isOpen.value ? nostrFrame : null,
})
const bridge = useNostrBridge(overlayIdentity, {
appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app',
appName: () => title.value || 'App',
@@ -542,25 +556,33 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const { handleNostrRequest, showConsent, consentRequest, consentPhase,
consentError, approveConsent, denyConsent } = bridge
function handleNativeRequest(event: MessageEvent) {
handleNostrRequest(event)
void registrationBridge.handle(event); void rentalBridge.handle(event)
}
function setNostrFrame(frame: Window | null) {
if (frame === nostrFrame) return
bridge.cancelPending()
registrationBridge.cancel(); rentalBridge.cancel()
nostrFrame = frame
}
watch(url, () => bridge.cancelPending(), { flush: 'sync' })
watch(url, () => { bridge.cancelPending(); registrationBridge.cancel(); rentalBridge.cancel() }, { flush: 'sync' })
onScopeDispose(() => {
window.removeEventListener('message', handleNostrRequest)
window.removeEventListener('message', handleNativeRequest)
bridge.dispose()
registrationBridge.dispose(); rentalBridge.dispose()
nostrFrame = null
})
// Listen for NIP-07 requests only while an app is open
watch(isOpen, (open) => {
if (open) {
window.addEventListener('message', handleNostrRequest)
window.addEventListener('message', handleNativeRequest)
} else {
window.removeEventListener('message', handleNostrRequest)
window.removeEventListener('message', handleNativeRequest)
bridge.cancelPending()
registrationBridge.cancel(); rentalBridge.cancel()
}
}, { flush: 'sync' })
@@ -585,6 +607,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
consentError,
approveConsent,
denyConsent,
setNativeIdentityBusy: (busy: boolean) => { nativeIdentityBusy.value = busy },
rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase,
cancelUnpaidRental: rentalBridge.cancelUnpaid, rentalError: rentalBridge.error, reviewRental: rentalBridge.review, approveRental: rentalBridge.approve, cancelRental: rentalBridge.cancel,
registrationRequest: registrationBridge.request,
registrationPhase: registrationBridge.phase,
registrationError: registrationBridge.error,
approveRegistration: registrationBridge.approve,
approveRegistrationResolution: registrationBridge.approveResolution,
cancelRegistration: registrationBridge.cancel,
setNostrFrame,
}
})
+27 -2
View File
@@ -91,6 +91,10 @@
<!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. -->
<RentalPurchaseConsent v-if="!nostrBridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
<MediaRegistrationConsent v-if="!nostrBridge.showConsent.value"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
<NostrSignConsent
:show="nostrBridge.showConsent.value"
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
@@ -123,6 +127,10 @@ import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -293,20 +301,33 @@ function closeRouteSession() {
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
const registrationBridge = useMediaRegistrationBridge({
consentBusy: (): boolean => rentalBridge.isBusy(),
appId: () => appId.value, appUrl: () => appUrl.value,
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
})
const rentalBridge = useRentalPurchaseBridge({
consentBusy: (): boolean => nostrBridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(),
appId: () => appId.value, appUrl: () => appUrl.value,
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
})
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
appId: () => appId.value,
appName: () => appTitle.value,
appUrl: () => appUrl.value,
frameWindow: () => iframeRef.value?.contentWindow ?? null,
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
})
// An actual destination change invalidates consent queued for the previous app page.
watch(() => props.suspended, suspended => { if (suspended) { nostrBridge.cancelPending(); registrationBridge.cancel(); rentalBridge.cancel() } }, { flush: 'sync' })
watch(appUrl, () => {
loadedAppUrl.value = ''
slowLoad.value = false
nostrBridge.cancelPending()
})
registrationBridge.cancel(); rentalBridge.cancel()
}, { flush: 'sync' })
// --- Display mode ---
@@ -514,6 +535,7 @@ function handleBackdropClick() {
function closeSession() {
nostrBridge.cancelPending()
registrationBridge.cancel(); rentalBridge.cancel()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@@ -543,6 +565,8 @@ function onFullscreenChange() {
function onMessage(e: MessageEvent) {
if (e.source !== iframeRef.value?.contentWindow) return
mediaBridge.handle(e)
if (props.suspended) return
void registrationBridge.handle(e); void rentalBridge.handle(e)
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
@@ -605,6 +629,7 @@ onMounted(() => {
onBeforeUnmount(() => {
nostrBridge.dispose()
registrationBridge.dispose(); rentalBridge.dispose()
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
+28 -1
View File
@@ -6,6 +6,10 @@
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
<RentalPurchaseConsent v-if="!bridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
<MediaRegistrationConsent v-if="!bridge.showConsent.value && !showIdentityPicker"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
<NostrSignConsent
:show="bridge.showConsent.value"
:app-name="bridge.consentRequest.value?.appName ?? appName"
@@ -25,6 +29,10 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
@@ -65,7 +73,7 @@ function hideSigner(delay = 0) {
if (hideTimer !== null) clearTimeout(hideTimer)
const hide = () => {
hideTimer = null
if (!showIdentityPicker.value && !bridge.showConsent.value) {
if (!showIdentityPicker.value && !bridge.showConsent.value && !registrationBridge.request.value && !rentalBridge.request.value) {
parentPost({ type: 'archipelago:signer-hide' })
}
}
@@ -89,6 +97,17 @@ function sendIdentity(identity: SelectedIdentity) {
parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity })
}
const registrationBridge = useMediaRegistrationBridge({
consentBusy: (): boolean => rentalBridge.isBusy(),
appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent,
})
const rentalBridge = useRentalPurchaseBridge({
consentBusy: (): boolean => bridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(),
appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent,
})
watch(rentalBridge.request, request => { if (request) showSigner(); else hideSigner() })
watch(registrationBridge.request, request => { if (request) showSigner(); else hideSigner() })
watch([appId, appOrigin], () => { registrationBridge.cancel(); rentalBridge.cancel() }, { flush: 'sync' })
const bridge = useNostrBridge(getStoredIdentity, {
appId: () => appId.value,
appName: () => appName.value,
@@ -185,6 +204,13 @@ function onMessage(event: MessageEvent) {
return
}
if (data.type === 'archipelago-rental-request' && appId.value && event.origin === appOrigin.value) {
void rentalBridge.handle(event); return
}
if (data.type === 'archipelago-media-registration-request' && appId.value && event.origin === appOrigin.value) {
void registrationBridge.handle(event)
return
}
if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return
if (!getStoredIdentity()) {
queuedRequests.push(event)
@@ -208,6 +234,7 @@ onMounted(() => {
window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
})
onBeforeUnmount(() => {
registrationBridge.dispose(); rentalBridge.dispose()
if (hideTimer !== null) clearTimeout(hideTimer)
window.removeEventListener('message', onMessage)
document.documentElement.classList.remove('nostr-signer-route')
+157 -8
View File
@@ -377,9 +377,10 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
@click.self="closePayModal"
>
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
<div class="glass-card w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto p-5 rounded-2xl relative">
<button
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
class="absolute top-1 right-1 min-h-11 min-w-11 flex items-center justify-center text-white/50 hover:text-white transition-colors"
aria-label="Close payment"
@click="closePayModal"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -461,18 +462,24 @@
<!-- Step 1b: ecash confirmation — show which wallet will be spent -->
<div v-else-if="payMode === 'ecash-confirm' && ecashPlan" class="space-y-4">
<div class="text-center py-2">
<div class="text-3xl font-bold text-white">{{ getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
<div class="text-3xl font-bold text-white">{{ ecashPlan.chosen === 'cashu' && cashuQuote ? cashuQuote.wallet_debit_sats : getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
<div class="text-xs text-white/50 mt-1">from your node’s ecash wallet</div>
</div>
<p v-if="ecashPlan.chosen === 'cashu' && cashuQuote" class="text-sm text-white/70 text-center">
<span class="block">Cashu · {{ cashuQuote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span>
<span class="block break-all">Mint: {{ cashuQuote.mint_url }}</span>
File: {{ cashuQuote.seller_net_sats }} sats · fees/rounding: {{ cashuQuote.wallet_debit_sats - cashuQuote.seller_net_sats }} sats
</p>
<p v-if="hasBlockingCashuPurchase" class="text-xs text-white/60">The original purchase is saved on your node. Retry recovers it without starting another payment.</p>
<!-- Backend selector: the chosen one is highlighted; the user can
switch to the other if it has enough balance. -->
<div class="space-y-2">
<button
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
:key="b"
@click="ecashPlan.chosen = b"
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
@click="selectEcashBackend(b)"
:disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && ecashBalanceOf(b) < getItemPrice(payItem.access))"
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
>
@@ -489,6 +496,7 @@
<p v-if="purchaseError" class="text-sm text-red-400">{{ purchaseError }}</p>
<button v-if="cashuQuote" class="w-full glass-button px-4 py-2.5 rounded-xl text-sm text-white/70" :disabled="paymentActionBusy" @click="cancelCashuPurchase">Cancel unpaid quote</button>
<div class="flex gap-2 pt-1">
<button
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
@@ -499,7 +507,7 @@
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
:disabled="!ecashPlan.chosen || paymentActionBusy"
@click="confirmEcashPay"
>{{ paymentActionBusy ? 'Paying…' : 'Pay' }}</button>
>{{ paymentActionBusy ? 'Working…' : ecashPlan.chosen === 'cashu' && !cashuQuote ? 'Check original purchase' : 'Pay' }}</button>
</div>
</div>
@@ -595,6 +603,7 @@
</template>
<script setup lang="ts">
import { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
@@ -835,6 +844,33 @@ const ecashPlan = ref<{
chosen: EcashBackend | null
} | null>(null)
const ecashPreparing = ref(false)
const cashuQuote = ref<CashuQuote | null>(null)
const cashuRecoveryRequired = ref(false)
const cashuRecoveryError = ref(false)
const hasBlockingCashuPurchase = computed(() => cashuRecoveryRequired.value || cashuRecoveryError.value)
let cashuLookup: Promise<void> = Promise.resolve()
async function lookupCashuPurchase(onion: string, item: CatalogItem, generation: number) {
const selected = () => paymentGeneration.value === generation && activePaymentMatches(onion, item.id)
try {
const state = await rpcClient.call<{attempts?: Array<{operation_id?: string;state?: string}>}>({method:'content.payment-status',params:{onion,content_id:item.id},timeout:15000})
if (!Array.isArray(state?.attempts)) throw new Error('Could not verify saved purchases; recover the original payment before choosing another method.')
if (selected() && state.attempts.some(attempt => attempt.state !== 'cancelled_unspent')) {
cashuRecoveryRequired.value=true
lnError.value='A Cashu purchase is saved on this node. Choose ecash to recover or cancel that operation.'
}
} catch (error) {
if (selected()) { cashuRecoveryError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify saved purchases' }
}
}
async function permitFreshOtherRail(item: CatalogItem, onion: string) {
const generation=paymentGeneration.value
await cashuLookup
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
return true
}
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
// on-chain first (the default).
const qrTab = ref<'onchain' | 'lightning'>('onchain')
@@ -877,6 +913,13 @@ function keepFailedLightningAttempt(onion: string, id: string, receipt: Lightnin
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
if (selected()) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
}
type InvoiceLifecycle = { paid?: boolean; state?: string; can_switch_method?: boolean }
function keepCanceledInvoice(onion: string, id: string, receipt: LightningReceipt, result: InvoiceLifecycle | undefined, selected: () => boolean) {
if (receipt.state === 'succeeded' || result?.paid !== false || result.state !== 'canceled' || result.can_switch_method !== true) return false
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: 'Seller confirmed the invoice is canceled and unpaid' }, selected)
if (selected()) lnError.value = 'The seller confirmed this invoice is canceled and unpaid. You can choose another payment method.'
return true
}
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)): Promise<'failed' | 'pending' | 'other'> {
// Old backends throw for terminal failures. Only LND's matching payment status
// can distinguish that from a lost reply; never infer failure from error text.
@@ -890,6 +933,14 @@ async function recoverFailedLightningAttempt(onion: string, id: string, receipt:
}
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
try {
const result = await rpcClient.call<InvoiceLifecycle>({
method: 'content.invoice-status', params: { onion, content_id: id, payment_hash: receipt.payment_hash }, timeout: 15000,
})
if (keepCanceledInvoice(onion, id, receipt, result, selected)) return 'failed'
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
else if (result?.state === 'open' || result?.state === 'accepted') return 'pending'
} catch { /* Seller outage or old boolean-only response cannot authorize another payment. */ }
return 'other'
}
const onchainPaying = ref(false)
@@ -1130,6 +1181,13 @@ async function downloadFile(item: CatalogItem) {
function openPayModal(item: CatalogItem) {
paymentGeneration.value++
cashuQuote.value = null
cashuRecoveryRequired.value = false
cashuRecoveryError.value = false
try {
const saved = readCashuAttempt(props.peerId || currentPeer.value?.onion || '', item.id)
if (saved) { cashuQuote.value = saved.quote; cashuRecoveryRequired.value = true }
} catch { cashuRecoveryError.value = true }
payItem.value = item
payMode.value = 'choose'
qrTab.value = 'onchain'
@@ -1152,6 +1210,7 @@ function openPayModal(item: CatalogItem) {
if (lnReceipt.value?.state === 'failed') lnError.value = `Previous Lightning attempt failed: ${lnReceipt.value.failure_reason || 'Payment failed'}. You can choose another method.`
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false
cashuLookup = lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)
}
function closePayModal() {
@@ -1161,6 +1220,9 @@ function closePayModal() {
payItem.value = null
payMode.value = 'choose'
ecashPlan.value = null
cashuQuote.value = null
cashuRecoveryRequired.value = false
cashuRecoveryError.value = false
ecashPreparing.value = false
invoiceWaiting.value = false
onchainWaiting.value = false
@@ -1232,6 +1294,7 @@ async function loadOnchainQr() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
if (!await permitFreshOtherRail(item, onion)) return
if (getItemPrice(item.access) < 546) { onchainError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
const operation = paymentOperations.begin('onchain-qr', onion, item.id)
if (!operation) return
@@ -1280,6 +1343,7 @@ async function payOnchain() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value) return
if (!await permitFreshOtherRail(item, onion)) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
if (getItemPrice(item.access) < 546) { lnError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
const operation = paymentOperations.begin('onchain-send', onion, item.id)
@@ -1348,6 +1412,9 @@ async function prepareEcashPay() {
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
const generation = paymentGeneration.value
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
if (!operation) return
const price = getItemPrice(item.access)
@@ -1372,12 +1439,15 @@ async function prepareEcashPay() {
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
const chosen: EcashBackend | null =
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!chosen) {
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
}
payMode.value = 'ecash-confirm'
if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false)
} catch (error) {
if (paymentOperations.selected(operation)) purchaseError.value = error instanceof Error ? error.message : 'Could not recover the Cashu purchase'
} finally {
if (paymentOperations.finish(operation)) ecashPreparing.value = false
}
@@ -1420,12 +1490,80 @@ function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeTy
void loadOwned()
}
type CashuPurchaseReply = Partial<Omit<CashuQuote, 'state'>> & { state?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }
async function requestCashuPurchase(item: CatalogItem, onion: string, operation: NonNullable<ReturnType<typeof paymentOperations.begin>>, confirm: boolean) {
const selected = () => paymentOperations.selected(operation)
let saved: ReturnType<typeof readCashuAttempt> = null
let unreadable = false
try { saved = readCashuAttempt(onion, item.id) } catch { unreadable = true }
// A corrupt browser marker may query/recover node-owned state, but cannot
// supply consent or authorize a newly selected payment.
const quote = unreadable ? null : saved?.quote || (selected() ? cashuQuote.value : null)
if (confirm && quote) keepCashuAttempt(onion, item.id, quote, true)
const result = await rpcClient.call<CashuPurchaseReply>({
method: 'content.purchase',
params: { onion, content_id: item.id, filename: item.filename,
max_wallet_debit: confirm && quote ? quote.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
...(confirm && quote ? { consent: { operation_id: quote.operation_id, envelope_sha256: quote.envelope_sha256, wallet_debit_sats: quote.wallet_debit_sats } } : {}) },
timeout: 960000, maxRetries: 1,
})
if (result?.state === 'confirmation_required') {
const next = parseCashuQuote(result)
keepAuthoritativeCashuQuote(onion, item.id, next)
if (selected()) { cashuQuote.value = next; cashuRecoveryRequired.value = true; cashuRecoveryError.value = false }
return
}
if (result?.state === 'delivered' && result.owned === true && result.owned_content_id) {
archiveMalformedCashuAttempt(onion, item.id)
clearCashuAttempt(onion, item.id)
if (selected()) openPurchased(item, undefined, result.mime_type, onion, result.owned_content_id)
return
}
if (result?.state === 'cancelled_unspent') {
archiveMalformedCashuAttempt(onion, item.id)
clearCashuAttempt(onion, item.id)
if (selected()) { cashuQuote.value = null; cashuRecoveryRequired.value = false; cashuRecoveryError.value = false; payMode.value = 'choose' }
return
}
throw new Error(result?.error || 'The original Cashu purchase is not confirmed yet. Retry recovers it; do not pay using another method.')
}
async function selectEcashBackend(backend: EcashBackend) {
if (!ecashPlan.value || paymentActionBusy.value) return
if (backend !== 'cashu' && hasBlockingCashuPurchase.value) { purchaseError.value = 'Cancel the original unpaid Cashu quote before selecting another wallet.'; return }
ecashPlan.value.chosen = backend
if (backend === 'cashu') await prepareEcashPay()
}
async function cancelCashuPurchase() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const quote = cashuQuote.value
if (!item || !onion || !quote || paymentActionBusy.value) return
const generation = paymentGeneration.value
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion,item.id)) return
const operation = paymentOperations.begin('cashu-cancel', onion, item.id)
if (!operation) return
try {
const result = await rpcClient.call<{state?: string;operation_id?: string}>({ method:'content.cancel-purchase',
params:{onion,operation_id:quote.operation_id}, timeout:60000,maxRetries:1 })
if (result?.state !== 'cancelled_unspent' || result.operation_id !== quote.operation_id) throw new Error('Cancellation is not confirmed. Recover the original purchase before paying another way.')
clearCashuAttempt(onion,item.id)
if (paymentOperations.selected(operation)) {
cashuQuote.value=null;cashuRecoveryRequired.value=false;cashuRecoveryError.value=false
purchaseError.value=null;payMode.value='choose'
}
} catch (error) {
if (paymentOperations.selected(operation)) purchaseError.value=error instanceof Error?error.message:'Could not confirm cancellation'
} finally { paymentOperations.finish(operation) }
}
/** Confirm the ecash payment with the backend the user selected. */
async function confirmEcashPay() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
if (method !== 'cashu' && !await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('ecash-send', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
@@ -1433,6 +1571,8 @@ async function confirmEcashPay() {
purchaseError.value = null
try {
if (method === 'cashu') { await requestCashuPurchase(item, onion, operation, true); return }
if (hasBlockingCashuPurchase.value) throw new Error('Recover or cancel the saved Cashu purchase first.')
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
@@ -1457,6 +1597,7 @@ async function payWithInvoice() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
if (!await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('invoice', onion, item.id)
if (!operation) return
payMode.value = 'qr'
@@ -1495,6 +1636,7 @@ async function payWithLightning() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
if (!await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('lightning', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
@@ -1568,11 +1710,18 @@ async function pollInvoice(scope: InvoicePollScope) {
if (!invoiceScopeSelected(scope)) return
const { item, onion, invoice: inv } = scope
try {
const res = await rpcClient.call<{ paid?: boolean }>({
const res = await rpcClient.call<InvoiceLifecycle>({
method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 30000,
})
if (!invoiceScopeSelected(scope)) return
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
invoiceWaiting.value = false
invoiceData.value = null
invoiceQr.value = ''
payMode.value = 'choose'
return
}
if (res?.paid === true) {
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify({ ...inv, state: 'succeeded' }))
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
@@ -7,8 +7,9 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64)
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } }
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const cashuQuoteFixture = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 }
const download = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
@@ -23,9 +24,10 @@ beforeEach(() => {
localStorage.clear(); vi.clearAllMocks()
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return cashuQuoteFixture
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
if (method === 'content.download-peer-invoice') return download()
return { items: [] }
return { items: [], attempts: [] }
})
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
})
@@ -34,7 +36,7 @@ describe('Lightning file delivery recovery', () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-status') return { paid: true }
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.pollOnchain('bc1test')
@@ -47,29 +49,29 @@ describe('Lightning file delivery recovery', () => {
})
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [] }
if (method === 'content.purchase') return { state: 'delivered', owned: true, owned_content_id: item.id, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
await vm.confirmEcashPay()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].maxRetries).toBe(1)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].params).toMatchObject({ content_id: item.id, max_wallet_debit: Number.MAX_SAFE_INTEGER })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].maxRetries).toBe(1)
wrapper.unmount()
})
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return await new Promise(resolve => { finish = resolve })
return { items: [] }
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
const first = vm.confirmEcashPay()
await vm.confirmEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.download-peer-paid')).toHaveLength(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.purchase')).toHaveLength(1)
finish({ error: 'Delivery needs recovery; do not pay again' })
await first
expect(vm.purchaseError).toContain('do not pay again')
@@ -214,9 +216,11 @@ describe('Lightning file delivery recovery', () => {
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
expect(typeof reply).toBe('function')
vm.closePayModal(); vm.openPayModal(item)
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
@@ -229,9 +233,11 @@ it('does not pay when an invoice arrives after closing and reopening the same fi
it('retains a late invoice for its original file without changing another file modal', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithInvoice()
await flushPromises()
expect(typeof reply).toBe('function')
await vm.payWithInvoice()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
@@ -247,11 +253,15 @@ it('retains a late invoice for its original file without changing another file m
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
const replies: ((value: unknown) => void)[] = []
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
? await new Promise(resolve => { replies.push(resolve) }) : { items: [] })
? await new Promise(resolve => { replies.push(resolve) }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const first = vm.prepareEcashPay()
await flushPromises()
expect(replies).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
const second = vm.prepareEcashPay()
await flushPromises()
expect(replies).toHaveLength(2)
replies[0]!({ cashu_sats: 100 })
await first
expect(vm.ecashPreparing).toBe(true)
@@ -267,7 +277,7 @@ it('cannot deliver a late paid invoice into another file modal', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.invoice-status') return { paid: true }
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
@@ -304,9 +314,11 @@ it('persists a dispatched Lightning result after closing without changing anothe
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
expect(typeof reply).toBe('function')
wrapper.unmount()
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
@@ -341,7 +353,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
@@ -404,3 +416,151 @@ it('retains already dispatched Lightning evidence after unmount without opening
})
})
describe('Seller-authoritative external invoice lifecycle', () => {
it('unlocks alternate methods only after the seller confirms the saved external invoice canceled and unpaid', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return { paid: false, state: 'canceled', can_switch_method: true }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
expect(vm.lnError).toContain('seller confirmed')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it.each([
{ paid: false },
{ paid: false, state: 'open', expires_at: 1, can_switch_method: false },
{ paid: false, state: 'unknown', can_switch_method: false },
{ paid: false, state: 'canceled' },
{ paid: false, state: 'accepted', can_switch_method: true },
])('retains the saved attempt when seller status does not prove terminal cancellation: %j', async response => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return response
if (method === 'content.download-peer-invoice') return { error: 'Payment is still pending' }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.request-invoice')).toBe(false)
wrapper.unmount()
})
})
describe('Durable node Cashu purchases', () => {
it('shows the exact quoted debit and confirms its immutable terms without the legacy send RPC', async () => {
let purchaseCalls = 0
vi.mocked(rpcClient.call).mockImplementation(async ({method}) => {
if (method === 'content.purchase') return ++purchaseCalls === 1 ? cashuQuoteFixture : {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open()
await vm.prepareEcashPay()
expect(vm.cashuQuote.wallet_debit_sats).toBe(7)
expect(wrapper.text()).toContain('fees/rounding: 2 sats')
expect(purchaseCalls).toBe(1)
await vm.confirmEcashPay()
const calls=vi.mocked(rpcClient.call).mock.calls.map(([call])=>call)
const requests=calls.filter(call=>call.method==='content.purchase')
expect(requests[0]?.params).not.toHaveProperty('consent')
expect(requests[1]?.params).toMatchObject({max_wallet_debit:7,consent:{operation_id:cashuQuoteFixture.operation_id,envelope_sha256:cashuQuoteFixture.envelope_sha256,wallet_debit_sats:7}})
expect(calls.some(call=>call.method==='content.download-peer-paid')).toBe(false)
expect(vm.viewerUrl).toContain('/paid-file')
wrapper.unmount()
})
it('recovers after a lost submit reply and remount without confirming another payment', async () => {
let count=0
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if (method==='content.purchase') {
count++
if(count===1)return cashuQuoteFixture
if(count===2)throw new Error('Connection lost; original purchase saved')
return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
}
return {items:[],attempts:[]}
})
const first=await open();await first.vm.prepareEcashPay();await first.vm.confirmEcashPay();first.wrapper.unmount()
const next=await open();expect(next.vm.hasBlockingCashuPurchase).toBe(true)
await next.vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
await next.vm.prepareEcashPay()
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(requests).toHaveLength(3)
expect(requests[2]?.[0].params).not.toHaveProperty('consent')
expect(next.vm.viewerUrl).toContain('/paid-file')
next.wrapper.unmount()
})
it('keeps the original operation until seller cancellation acknowledgement, then permits a new quote', async () => {
let canceled=false, cancelCalls=0
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.purchase')return canceled?{...cashuQuoteFixture,operation_id:'87654321-1234-4234-8234-123456789abc'}:cashuQuoteFixture
if(method==='content.cancel-purchase'){
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
canceled=true;return {state:'cancelled_unspent',operation_id:cashuQuoteFixture.operation_id}
}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
expect(vm.hasBlockingCashuPurchase).toBe(true)
await vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
await vm.cancelCashuPurchase();expect(vm.hasBlockingCashuPurchase).toBe(false)
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
wrapper.unmount()
})
it('finds a node-owned pending purchase after browser state loss before another rail can dispatch', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.payment-status')return {attempts:[{operation_id:cashuQuoteFixture.operation_id,state:'token_prepared_settlement_unconfirmed'}]}
if(method==='content.purchase')return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.request-invoice')).toBe(false)
await vm.prepareEcashPay();expect(vm.viewerUrl).toContain('/paid-file')
wrapper.unmount()
})
})
describe('Malformed browser Cashu marker recovery', () => {
const marker='peer-file-cashu:peer.onion:paid-file'
it('queries node-owned state without consent, archives the malformed marker and restores the authoritative quote', async () => {
localStorage.setItem(marker,'{original broken marker')
const {wrapper,vm}=await open()
expect(vm.cashuRecoveryError).toBe(true)
await vm.prepareEcashPay()
expect(localStorage.getItem(`${marker}:unreadable`)).toBe('{original broken marker')
expect(JSON.parse(localStorage.getItem(marker)!)).toMatchObject({quote:cashuQuoteFixture,dispatched:false})
expect(vm.cashuRecoveryError).toBe(false)
expect(vm.hasBlockingCashuPurchase).toBe(true)
const calls=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(calls).toHaveLength(1)
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
localStorage.setItem(marker,'{original broken marker')
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.purchase')throw new Error('Node purchase journal is unavailable')
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.payWithLightning()
expect(localStorage.getItem(marker)).toBe('{original broken marker')
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
expect(vm.hasBlockingCashuPurchase).toBe(true)
expect(vm.purchaseError).toContain('journal is unavailable')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -106,7 +106,7 @@ describe('PeerFiles', () => {
}
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') return { items: [freeImage] }
if (req.method === 'content.owned-list') return { items: [] }
if (req.method === 'content.owned-list') return { items: [], attempts: [] }
return {}
}) as never)