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 } 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 } 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, b = right as Record 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(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({ 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 } | 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 } }