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)
|
||||
})
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user