Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
@@ -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)
})
})