Integrate recoverable native purchases, registered rentals and explicit payment consent
This commit is contained in:
@@ -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 } }
|
||||
}
|
||||
Reference in New Issue
Block a user