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)
})
})
@@ -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 } }
}