Draft explicit rental readiness and start with verified chunk delivery
This commit is contained in:
@@ -291,7 +291,7 @@
|
||||
if (e.data.type === 'archipelago-rental-response') {
|
||||
var rental = rentalPending[e.data.id];
|
||||
if (!rental) return;
|
||||
delete rentalPending[e.data.id]; clearTimeout(rental.timer);
|
||||
delete rentalPending[e.data.id]; clearTimeout(rental.timer); rental.cleanup();
|
||||
e.data.error ? rental.reject(new Error(e.data.error)) : rental.resolve(e.data.result);
|
||||
return;
|
||||
}
|
||||
@@ -336,26 +336,36 @@
|
||||
return hex.slice(0,8)+'-'+hex.slice(8,12)+'-'+hex.slice(12,16)+'-'+hex.slice(16,20)+'-'+hex.slice(20);
|
||||
}
|
||||
|
||||
function rentalRequest(action, payload, timeout, signal) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (signal && (typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function' || typeof signal.aborted !== 'boolean')) { reject(new Error('Invalid rental cancellation signal.')); return; }
|
||||
if (signal && signal.aborted) { reject(new Error('Rental request closed.')); return; }
|
||||
var id = nativeRequestId();
|
||||
function cleanup() { if (signal) signal.removeEventListener('abort', abort); }
|
||||
function fail(message) {
|
||||
var pending = rentalPending[id]; if (!pending) return;
|
||||
delete rentalPending[id]; clearTimeout(pending.timer); cleanup();
|
||||
if (action === 'request') postToSigner({type:'archipelago-rental-request',id:id,action:'cancel',playbackProtocol:2});
|
||||
reject(new Error(message));
|
||||
}
|
||||
function abort() { fail('Rental request closed. Recover any dispatched payment using the original purchase.'); }
|
||||
rentalPending[id] = { resolve: resolve, reject: reject, cleanup: cleanup, timer: setTimeout(function () {
|
||||
fail('Rental response unavailable. Recover the same purchase without paying again.');
|
||||
}, timeout) };
|
||||
if (signal) signal.addEventListener('abort', abort, {once:true});
|
||||
postToSigner(Object.assign({ type: 'archipelago-rental-request', id: id, action: action, playbackProtocol: 2 }, payload));
|
||||
});
|
||||
}
|
||||
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 });
|
||||
});
|
||||
status: function (handle) { return rentalRequest('status', { handle: handle }, 35000); },
|
||||
prepare: function (handle, retry) { return rentalRequest('prepare', { handle: handle, retry: retry === true }, 35000); },
|
||||
start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); },
|
||||
request: function (offer, options) {
|
||||
if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.'));
|
||||
return rentalRequest('request', { offer: offer }, 600000, options.signal);
|
||||
}
|
||||
};
|
||||
Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true });
|
||||
|
||||
window.archipelagoMediaRegistration = {
|
||||
request: function (action, payload) {
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
</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" />
|
||||
<RentalPurchaseConsent v-if="!store.showConsent && !showIdentityPicker && !store.registrationRequest" :request="store.rentalRequest" :quote="store.rentalQuote" :phase="store.rentalPhase" :progress="store.rentalProgress" :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" />
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<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>
|
||||
<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 you press Start</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>
|
||||
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'preparing' ? progress : 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>
|
||||
@@ -16,10 +16,10 @@
|
||||
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 props = defineProps<{ request: RentalOffer | null; quote: { wallet_debit_sats: number; network: 'mainnet' | 'testnet'; mint_url: string } | null; phase: string; error: string; progress?: 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')
|
||||
const busy = computed(() => props.phase === 'preparing' || props.phase === 'loading' || props.phase === 'paying')
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ describe('native provider on ordinary HTTP node origins',()=>{
|
||||
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'})
|
||||
expect(window.archipelagoRental.playbackProtocol).toBe(2)
|
||||
expect(Object.getOwnPropertyDescriptor(window.archipelagoRental,'playbackProtocol')?.writable).toBe(false)
|
||||
await expect(window.archipelagoRental.request({title:'Legacy'})).rejects.toThrow('protocol 2')
|
||||
expect(parent.postMessage).not.toHaveBeenCalled()
|
||||
const rental=window.archipelagoRental.request({title:'Film'},{playbackProtocol:2})
|
||||
const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}})
|
||||
const requests=parent.postMessage.mock.calls.map(([message])=>message)
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -19,5 +23,19 @@ describe('native provider on ordinary HTTP node origins',()=>{
|
||||
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)
|
||||
parent.postMessage.mockClear()
|
||||
const stop = new AbortController()
|
||||
const closed = window.archipelagoRental.request({title:'Closed'}, {playbackProtocol:2,signal:stop.signal})
|
||||
const closedCheck = expect(closed).rejects.toThrow('Rental request closed')
|
||||
const retained = window.archipelagoRental.request({title:'Other'}, {playbackProtocol:2})
|
||||
const messages=parent.postMessage.mock.calls.map(([message])=>message)
|
||||
stop.abort();await closedCheck
|
||||
expect(parent.postMessage.mock.lastCall![0]).toMatchObject({action:'cancel',id:messages[0].id,playbackProtocol:2})
|
||||
expect(timers.size).toBe(1)
|
||||
for(const receive of listeners)receive({source:parent,origin:'http://node.local',data:{type:'archipelago-rental-response',id:messages[1].id,result:{playbackProtocol:2,handle:'d'.repeat(64)}}})
|
||||
await expect(retained).resolves.toMatchObject({playbackProtocol:2});expect(timers.size).toBe(0)
|
||||
parent.postMessage.mockClear()
|
||||
await expect(window.archipelagoRental.request({title:'Already closed'},{playbackProtocol:2,signal:stop.signal})).rejects.toThrow('closed')
|
||||
expect(parent.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ const quote = { state: 'confirmation_required', network: 'mainnet', mint_url: 'h
|
||||
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)
|
||||
const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', playbackProtocol: 2, 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 }) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
|
||||
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 : { handle: '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()
|
||||
@@ -26,12 +26,12 @@ describe('native rental confirmation', () => {
|
||||
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})
|
||||
rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{handle:'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/')
|
||||
expect(f.child.postMessage.mock.lastCall![0].result).toEqual({playbackProtocol:2,handle:'d'.repeat(64),expires_at:null,operation_id:quote.operation_id}); expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
})
|
||||
it('does not dispatch after closing during installation validation', async()=>{
|
||||
const f=fixture(); await f.send(); let release!:(value:unknown)=>void
|
||||
@@ -63,7 +63,7 @@ describe('native rental confirmation', () => {
|
||||
})
|
||||
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)
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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()
|
||||
@@ -83,7 +83,7 @@ describe('native rental 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)
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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()
|
||||
@@ -91,7 +91,7 @@ describe('native rental confirmation', () => {
|
||||
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)
|
||||
await bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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)
|
||||
@@ -111,3 +111,87 @@ describe('native rental confirmation', () => {
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('protocol 2 preparation and explicit Start',()=>{
|
||||
it('rejects legacy callers before a wallet request',async()=>{
|
||||
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(rpc.call).not.toHaveBeenCalled();expect(f.child.postMessage.mock.lastCall![0].error).toContain('protocol 2')
|
||||
})
|
||||
it('closing while preparation waits cancels polling and never approves payment',async()=>{
|
||||
vi.useFakeTimers(); const f=fixture();await f.send()
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:4,total_bytes:16})
|
||||
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.bridge.phase.value).toBe('preparing');expect(f.bridge.progress.value).toContain('25%')
|
||||
f.bridge.cancel();await work;await vi.advanceTimersByTimeAsync(10000)
|
||||
const payments=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase');expect(payments).toHaveLength(1);expect(payments[0]![0].params.consent).toBeUndefined()
|
||||
})
|
||||
it('preparation reaches confirmation without automatically accepting it',async()=>{
|
||||
vi.useFakeTimers();const f=fixture();await f.send();let polls=0
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:++polls===1?{state:'preparing',completed_bytes:0,total_bytes:16}:quote)
|
||||
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(2000);await work
|
||||
expect(f.bridge.phase.value).toBe('confirm');expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase').every(([v])=>v.params.consent===undefined)).toBe(true)
|
||||
})
|
||||
it('only a distinct Start action returns the local playback URL',async()=>{
|
||||
const f=fixture();const ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle:'d'.repeat(64),viewing_seconds:3600,started_at:null,expires_at:null}:{state:'started',started_at:100,expires_at:3700,playback_url:'/api/rental-playback/'+'d'.repeat(64)})
|
||||
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle:'d'.repeat(64),ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('prepare'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBeUndefined();expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
await f.bridge.handle(event('start'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBe('https://node.test/api/rental-playback/'+'d'.repeat(64))
|
||||
expect(rpc.call.mock.calls.filter(([v])=>v.method==='media.registration.context')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider request cancellation ownership',()=>{
|
||||
it('cancels only the matching native request and cannot approve its old quote',async()=>{
|
||||
const f=fixture();await f.send();await f.bridge.review()
|
||||
const event=(id:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,action:'cancel',id},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'));expect(f.bridge.phase.value).toBe('confirm')
|
||||
await f.bridge.handle(event('cccccccc-cccc-4ccc-8ccc-cccccccccccc'));expect(f.bridge.request.value).toBeNull()
|
||||
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it('drops Start after closing during installed-frame verification',async()=>{
|
||||
const f=fixture();let release!:(value:unknown)=>void
|
||||
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
f.bridge.cancel();release(installed);await work
|
||||
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('strict protocol responses and approved-operation recovery',()=>{
|
||||
it('rejects unknown actions rather than opening a purchase',async()=>{
|
||||
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'unexpected',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(rpc.call).not.toHaveBeenCalled();expect(f.bridge.request.value).toBeNull()
|
||||
})
|
||||
it('preserves approved operation when a preparing response arrives after consent',async()=>{
|
||||
const f=fixture();await f.send();await f.bridge.review()
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:0,total_bytes:16})
|
||||
await f.bridge.approve();expect(f.bridge.error.value).toContain('Payment was approved');expect(f.bridge.error.value).toContain('original operation');expect(f.bridge.quote.value?.operation_id).toBe(quote.operation_id)
|
||||
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it.each([
|
||||
{state:'ready',ready_id:'invalid',handle:'d'.repeat(64),viewing_seconds:60,started_at:null,expires_at:null},
|
||||
{state:'preparing',completed_bytes:20,total_bytes:10,viewing_seconds:60,started_at:null,expires_at:null},
|
||||
{state:'ready',ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',handle:'d'.repeat(64),viewing_seconds:0,started_at:null,expires_at:null},
|
||||
{state:'expired',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)},
|
||||
])('rejects malformed or URL-bearing non-started states',async(result)=>{
|
||||
const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:result)
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'prepare',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(f.child.postMessage.mock.lastCall![0].error).toContain('Playback state');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
|
||||
})
|
||||
it('drops a completed Start response from a closed frame generation',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',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await vi.waitFor(()=>expect(release).toBeTypeOf('function'));f.bridge.cancel();release({state:'started',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)});await work
|
||||
expect(f.child.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a changed original viewing window on a recovered Start',async()=>{
|
||||
const f=fixture();const handle='d'.repeat(64),ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle,viewing_seconds:3600,started_at:100,expires_at:3700}:{state:'started',started_at:200,expires_at:3800,playback_url:'/api/rental-playback/'+handle})
|
||||
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle,ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('prepare'));await f.bridge.handle(event('start'))
|
||||
expect(f.child.postMessage.mock.lastCall![0].error).toContain('original viewing window');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -6,6 +6,47 @@ interface FrameContext { appId: () => string; appUrl: () => string; frameWindow:
|
||||
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 }
|
||||
type PlaybackAction = 'status' | 'prepare' | 'start'
|
||||
interface PlaybackBinding { duration?: number; startedAt?: number; expiresAt?: number }
|
||||
const readyPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const positiveInteger = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0
|
||||
function validatePlayback(raw: unknown, action: PlaybackAction, handle: string, original: PlaybackBinding): { result: Record<string, unknown>; binding: PlaybackBinding } {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid playback response.')
|
||||
const data = raw as Record<string, unknown>, binding = { ...original }
|
||||
const failure = () => { throw new Error('Playback state or original viewing window changed. Recover this purchase without paying again.') }
|
||||
const expires = data.expires_at
|
||||
if (!(expires === null || positiveInteger(expires))) return failure()
|
||||
if (binding.expiresAt !== undefined && expires !== binding.expiresAt) return failure()
|
||||
if (positiveInteger(expires)) binding.expiresAt = expires
|
||||
if (action === 'status') {
|
||||
if ('playback_url' in data) return failure()
|
||||
return { result: { expires_at: expires }, binding }
|
||||
}
|
||||
const state = data.state
|
||||
if (state !== 'started' && 'playback_url' in data) return failure()
|
||||
if (state === 'unavailable' && action === 'prepare') return { result: {state, expires_at: expires}, binding }
|
||||
const start = data.started_at
|
||||
if (!((start === null && expires === null) || (positiveInteger(start) && positiveInteger(expires) && expires > start))) return failure()
|
||||
if (binding.startedAt !== undefined && start !== binding.startedAt) return failure()
|
||||
if (positiveInteger(start)) binding.startedAt = start
|
||||
if (state === 'preparing' || state === 'ready') {
|
||||
if (action !== 'prepare' || !positiveInteger(data.viewing_seconds)) return failure()
|
||||
if (binding.duration !== undefined && data.viewing_seconds !== binding.duration) return failure()
|
||||
binding.duration = data.viewing_seconds
|
||||
if (positiveInteger(start) && expires !== start + binding.duration) return failure()
|
||||
const base = {state, viewing_seconds: binding.duration, started_at: start, expires_at: expires}
|
||||
if (state === 'preparing') {
|
||||
if (!positiveInteger(data.total_bytes) || !Number.isSafeInteger(data.completed_bytes) || (data.completed_bytes as number) < 0 || (data.completed_bytes as number) > data.total_bytes) return failure()
|
||||
return { result: {...base,completed_bytes:data.completed_bytes,total_bytes:data.total_bytes}, binding }
|
||||
}
|
||||
if (data.handle !== handle || typeof data.ready_id !== 'string' || !readyPattern.test(data.ready_id)) return failure()
|
||||
return { result: {...base,handle,ready_id:data.ready_id}, binding }
|
||||
}
|
||||
if (!positiveInteger(start) || !positiveInteger(expires) || (binding.duration !== undefined && expires !== start + binding.duration)) return failure()
|
||||
if (state === 'expired') return { result: {state,started_at:start,expires_at:expires}, binding }
|
||||
if (state !== 'started' || action !== 'start' || data.playback_url !== `/api/rental-playback/${handle}`) return failure()
|
||||
return { result: {state,started_at:start,expires_at:expires,playback_url:new URL(data.playback_url as string,window.location.origin).href}, binding }
|
||||
}
|
||||
export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin: string): boolean {
|
||||
try {
|
||||
const app = new URL(appOrigin), dashboard = new URL(dashboardOrigin)
|
||||
@@ -17,7 +58,10 @@ export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin:
|
||||
}
|
||||
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('')
|
||||
const phase = ref<'review' | 'loading' | 'preparing' | 'confirm' | 'paying'>('review'), error = ref('')
|
||||
const progress = ref('')
|
||||
const playbackBindings = new Map<string, PlaybackBinding>()
|
||||
let stopWait: (() => void) | undefined
|
||||
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'
|
||||
@@ -29,6 +73,7 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
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.')
|
||||
stopWait?.(); stopWait = undefined; progress.value = ''
|
||||
generation++
|
||||
pending = null; request.value = null; quote.value = null; error.value = ''; phase.value = 'review'
|
||||
}
|
||||
@@ -41,30 +86,48 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
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 = ''
|
||||
phase.value = confirm ? 'paying' : 'loading'; error.value = ''; progress.value = ''
|
||||
try {
|
||||
const terms = item.offer.terms
|
||||
const deadline = Date.now() + 300000
|
||||
let result!: Quote & { completed_bytes?: number; total_bytes?: number }
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
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: {
|
||||
if (Date.now() >= deadline) throw new Error('Preparation is still running. Check purchase again; no new payment was approved.')
|
||||
result = await rpcClient.call<Quote & { completed_bytes?: number; total_bytes?: number }>({ 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,
|
||||
...(!confirm && attempt === 0 ? { retry_preparation: true } : {}),
|
||||
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 })
|
||||
}, timeout: Math.min(120000, deadline - Date.now()) })
|
||||
if (!current(item)) return
|
||||
if (result.state !== 'preparing') break
|
||||
if (confirm) throw new Error('Payment was approved for the original operation. Check the original purchase to recover its payment and preparation; do not pay again.')
|
||||
if (!Number.isSafeInteger(result.completed_bytes) || !Number.isSafeInteger(result.total_bytes)
|
||||
|| result.total_bytes! < 1 || result.completed_bytes! < 0 || result.completed_bytes! > result.total_bytes!) throw new Error('Invalid media preparation response. No new payment approved.')
|
||||
phase.value = 'preparing'
|
||||
progress.value = `Preparing video: ${Math.floor(result.completed_bytes! / result.total_bytes! * 100)}%. Your viewing time has not started.`
|
||||
if (attempt === 149) throw new Error('Video preparation is still running. Check purchase again to continue; no new payment was approved.')
|
||||
await new Promise<void>(resolve => { const timer = setTimeout(() => { stopWait = undefined; resolve() }, 2000); stopWait = () => { clearTimeout(timer); resolve() } })
|
||||
if (!current(item)) return
|
||||
}
|
||||
progress.value = ''
|
||||
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 (!readyPattern.test(result.operation_id)) throw new Error('Invalid original purchase identifier. Recover the original payment.')
|
||||
const playback = await rpcClient.call<{ handle: 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 })
|
||||
if (!/^[0-9a-f]{64}$/.test(playback.handle) || !(playback.expires_at === null || positiveInteger(playback.expires_at))) throw new Error('Invalid playback handle.')
|
||||
playbackBindings.set(`${item.origin}:${playback.handle}`, {duration:terms.viewingSeconds,...(playback.expires_at === null ? {} : {expiresAt:playback.expires_at})})
|
||||
reply(item, { playbackProtocol: 2, handle: playback.handle, expires_at: playback.expires_at, 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' }
|
||||
@@ -72,7 +135,7 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
}
|
||||
async function cancelUnpaid() {
|
||||
const item = pending
|
||||
if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading') return
|
||||
if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading' || phase.value === 'preparing') return
|
||||
phase.value = 'loading'; error.value = ''
|
||||
try {
|
||||
await installed(item)
|
||||
@@ -90,21 +153,34 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
|| 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') {
|
||||
if (event.data.action === 'cancel') { if (pending?.id === id && event.data.playbackProtocol === 2) cancel(); return }
|
||||
if (['status', 'prepare', 'start'].includes(event.data.action)) {
|
||||
const source = event.source as Window, origin = event.origin, handle = event.data.handle, scope = generation
|
||||
const action = event.data.action as PlaybackAction, readyId = event.data.ready_id
|
||||
if (event.data.playbackProtocol !== 2) { source.postMessage({type:'archipelago-rental-response',id,error:'Playback protocol 2 is required.'},origin); return }
|
||||
if (action === 'start' && !supportsRentalPlaybackOrigin(origin, window.location.origin)) { source.postMessage({type:'archipelago-rental-response',id,error:'Open the app from this node dashboard before starting playback.'},origin); return }
|
||||
if (action === 'start' && (typeof readyId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(readyId))) return
|
||||
if (action === 'prepare' && event.data.retry !== undefined && typeof event.data.retry !== 'boolean') return
|
||||
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)
|
||||
const result = await rpcClient.call<Record<string, unknown>>({ method: `content.playback-${action}`, params: { handle,
|
||||
...(action === 'prepare' ? { retry: event.data.retry === true } : {}), ...(action === 'start' ? {ready_id: readyId} : {}) }, timeout: 30000 })
|
||||
if (!selected()) return
|
||||
const key = `${origin}:${handle}`
|
||||
const validated = validatePlayback(result, action, handle, playbackBindings.get(key) ?? {})
|
||||
playbackBindings.set(key, validated.binding)
|
||||
source.postMessage({ type: 'archipelago-rental-response', id, result: validated.result }, origin)
|
||||
} catch (cause) {
|
||||
if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, error: String(cause) }, origin)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.data.action !== undefined && event.data.action !== 'request') { (event.source as Window).postMessage({type:'archipelago-rental-response',id,error:'Unsupported rental action.'},event.origin); return }
|
||||
if (event.data.playbackProtocol !== 2) { (event.source as Window).postMessage({type:'archipelago-rental-response',id,error:'Update this app to playback protocol 2 before renting. No payment was requested.'},event.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 }
|
||||
@@ -121,6 +197,6 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|
||||
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,
|
||||
return { isBusy: () => pending !== null, request, quote, phase, error, progress, handle, cancelUnpaid, review: () => run(false), approve: () => run(true), cancel,
|
||||
dispose: () => { cancel(); disposed = true } }
|
||||
}
|
||||
|
||||
@@ -608,7 +608,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
approveConsent,
|
||||
denyConsent,
|
||||
setNativeIdentityBusy: (busy: boolean) => { nativeIdentityBusy.value = busy },
|
||||
rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase,
|
||||
rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase, rentalProgress: rentalBridge.progress,
|
||||
cancelUnpaidRental: rentalBridge.cancelUnpaid, rentalError: rentalBridge.error, reviewRental: rentalBridge.review, approveRental: rentalBridge.approve, cancelRental: rentalBridge.cancel,
|
||||
registrationRequest: registrationBridge.request,
|
||||
registrationPhase: registrationBridge.phase,
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
<!-- 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" />
|
||||
<RentalPurchaseConsent v-if="!nostrBridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :progress="rentalBridge.progress.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" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@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" />
|
||||
<RentalPurchaseConsent v-if="!bridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :progress="rentalBridge.progress.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" />
|
||||
|
||||
Reference in New Issue
Block a user