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
@@ -181,6 +181,10 @@
</div>
</Transition>
<RentalPurchaseConsent v-if="!store.showConsent && !showIdentityPicker && !store.registrationRequest" :request="store.rentalRequest" :quote="store.rentalQuote" :phase="store.rentalPhase" :error="store.rentalError" @cancel-unpaid="store.cancelUnpaidRental" @review="store.reviewRental" @approve="store.approveRental" @cancel="store.cancelRental" />
<MediaRegistrationConsent v-if="!store.showConsent"
:request="store.registrationRequest" :phase="store.registrationPhase" :error="store.registrationError"
@approve="store.approveRegistration" @resolve="store.approveRegistrationResolution" @cancel="store.cancelRegistration" />
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
@@ -211,6 +215,8 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
@@ -275,6 +281,7 @@ watch(iframeLoading, (loading) => {
// Nostr identity picker state
const showIdentityPicker = ref(false)
watch(showIdentityPicker, value => store.setNativeIdentityBusy(value), { flush: 'sync' })
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
@@ -0,0 +1,71 @@
<template>
<div v-if="request" class="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6">
<section ref="modal" role="dialog" aria-modal="true" :aria-labelledby="titleId"
:aria-busy="loading || phase === 'preparing'" class="glass-card flex max-h-[90%] w-full max-w-xl flex-col overflow-hidden rounded-2xl p-4 sm:p-6">
<header class="flex items-start gap-3">
<div class="min-w-0 flex-1"><p class="text-xs text-white/50">IndeeHub · Cloud video</p>
<h2 :id="titleId" class="mt-1 text-xl font-semibold text-white">{{ phase === 'resolve' ? 'Recover your video registration' : phase === 'select' ? 'Choose your project video' : phase === 'signing' ? 'Approve your producer signature' : 'Preparing your video' }}</h2></div>
<button type="button" aria-label="Close video registration" class="min-h-11 min-w-11 rounded-lg text-white/70 hover:bg-white/10" @click="emit('cancel')">✕</button>
</header>
<dl class="my-4 grid grid-cols-2 gap-3 rounded-xl border border-white/10 p-3 text-sm">
<div class="col-span-2 min-w-0"><dt class="text-white/45">Project</dt><dd class="break-words text-white">{{ request.intent.projectId }}</dd></div>
<div><dt class="text-white/45">Price</dt><dd class="text-white">{{ request.intent.priceSats }} sats</dd></div>
<div><dt class="text-white/45">Access after first play</dt><dd class="text-white">{{ duration }}</dd></div>
</dl>
<p v-if="error || localError" role="alert" class="mb-3 rounded-lg border border-red-400/30 p-3 text-sm text-red-200">{{ error || localError }}</p>
<div v-if="phase === 'resolve'" class="space-y-4 py-3 text-sm text-white/70">
<p>The node will recover this request's completed video and receipt. If it expired before completion, the node will retire the request so you can start another.</p>
<button type="button" class="glass-button min-h-11 w-full rounded-lg border-orange-400/30 px-4 py-3 text-orange-200" @click="emit('resolve')">Recover or retire expired request</button>
</div>
<template v-else-if="phase === 'select'">
<div class="mb-2 flex items-center gap-2"><button type="button" :disabled="directory === '/' || loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75 disabled:opacity-40" @click="up">Up</button><p class="min-w-0 flex-1 break-all text-xs text-white/50">Cloud{{ directory }}</p><button type="button" :disabled="loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75" @click="load(directory)">Refresh</button></div>
<div class="min-h-24 overflow-y-auto rounded-xl border border-white/10">
<p v-if="loading" role="status" class="p-4 text-sm text-white/55">Loading Cloud files…</p>
<p v-else-if="!visible.length" class="p-4 text-sm text-white/55">No supported videos here. Choose an MP4, WebM or MOV file.</p>
<button v-for="file in visible" :key="file.path" type="button" :disabled="loading"
:aria-pressed="!file.isDir && selected?.path === file.path" class="flex min-h-12 w-full items-center gap-3 border-b border-white/5 px-3 py-2 text-left text-sm hover:bg-white/10"
:class="selected?.path === file.path ? 'bg-orange-400/15 text-orange-200' : 'text-white/80'" @click="choose(file)">
<span aria-hidden="true">{{ file.isDir ? '▸' : '▷' }}</span><span class="min-w-0 flex-1 break-words">{{ file.name }}</span>
<span v-if="!file.isDir" class="shrink-0 text-xs text-white/40">{{ formatSize(file.size) }}</span>
</button>
</div>
<p class="my-3 text-xs leading-relaxed text-white/50">The node keeps a fixed copy for this project's rental terms. This step prepares the video; publishing remains a separate action.</p>
<footer class="mt-auto flex flex-col-reverse gap-2 pt-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="emit('cancel')">Cancel</button><button type="button" :disabled="!selected || loading" class="glass-button min-h-11 flex-1 rounded-lg border-orange-400/30 px-4 text-orange-200 disabled:opacity-40" @click="approve">Use this video</button></footer>
</template>
<div v-else role="status" class="py-5 text-sm leading-relaxed text-white/65">
<p class="break-all">{{ request.selection?.relative_path }}</p>
<p class="mt-3">{{ request.resolution ? (phase === 'signing' ? 'Sign the request to recover its original result or retire it if it expired before completion.' : 'Checking the original operation. Keep this request until its result is confirmed.') : (phase === 'signing' ? 'Sign the exact project, video and terms with your active producer identity. Your signing key stays in its signer.' : 'Creating the fixed video copy and durable registration. You can reopen the same operation if the connection is interrupted.') }}</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RegistrationRequest, CloudSelection } from '@/composables/useMediaRegistrationBridge'
const props = defineProps<{ request: RegistrationRequest | null; phase: 'select' | 'resolve' | 'signing' | 'preparing'; error: string }>()
const emit = defineEmits<{ approve: [selection: CloudSelection]; cancel: []; resolve: [] }>()
const modal = ref<HTMLElement | null>(null), directory = ref('/'), files = ref<FileBrowserItem[]>([])
const selected = ref<FileBrowserItem | null>(null), loading = ref(false), localError = ref('')
const titleId = `media-registration-${crypto.randomUUID()}`
let generation = 0
const visible = computed(() => files.value.filter(item => item.isDir || /\.(mp4|m4v|webm|mov)$/i.test(item.name)))
const duration = computed(() => { const seconds = props.request?.intent.viewingSeconds ?? 0; return seconds % 3600 === 0 ? `${seconds / 3600} hours` : `${Math.ceil(seconds / 60)} minutes` })
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
async function load(path: string) {
const run = ++generation; loading.value = true; localError.value = ''; selected.value = null
try {
if (!await fileBrowserClient.login()) throw new Error('Cloud files could not be opened. Check your node connection and try again.')
const result = await fileBrowserClient.listDirectory(path)
if (run !== generation) return
files.value = result; directory.value = path
} catch (error) { if (run === generation) localError.value = error instanceof Error ? error.message : 'Cloud files could not be loaded.' }
finally { if (run === generation) loading.value = false }
}
function up() { const pieces = directory.value.split('/').filter(Boolean); pieces.pop(); void load('/' + pieces.join('/')) }
function choose(file: FileBrowserItem) { if (file.isDir) void load(file.path); else selected.value = file }
function approve() { if (!selected.value) return; emit('approve', { relative_path: selected.value.path.replace(/^\/+/, ''), payment_methods: ['cashu'] }) }
function formatSize(bytes: number) { return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${(bytes / 1024 ** 2).toFixed(1)} MB` }
watch(() => props.request?.intent.requestId, id => { ++generation; files.value = []; selected.value = null; localError.value = ''; if (id && props.phase === 'select') void load('/'); else loading.value = false }, { immediate: true })
</script>
+18 -2
View File
@@ -28,7 +28,12 @@
<div v-else class="mb-5 space-y-2">
<div class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Request</p><p class="text-sm font-medium text-white">{{ methodLabel }}</p></div>
<div v-if="identityLabel" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Identity</p><p class="text-sm font-medium text-white">{{ identityLabel }}</p></div>
<div v-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="mediaApproval" class="space-y-2 rounded-xl border border-white/10 bg-black/20 p-3 text-sm">
<p class="text-white/75">{{ mediaApproval.resolving ? 'Recover the completed registration or retire its expired incomplete request.' : 'Register this video for the selected IndeeHub project.' }}</p>
<dl class="space-y-2"><div><dt class="text-xs text-white/45">Project</dt><dd class="break-all text-white">{{ mediaApproval.project }}</dd></div><div v-if="mediaApproval.file"><dt class="text-xs text-white/45">Cloud video</dt><dd class="break-all text-white">{{ mediaApproval.file }}</dd></div><div><dt class="text-xs text-white/45">Rental terms</dt><dd class="text-white">{{ mediaApproval.price }} sats · {{ mediaApproval.seconds }} seconds after first play</dd></div></dl>
<details><summary class="min-h-11 cursor-pointer py-3 text-white/65">Full signed terms</summary><pre class="max-h-48 overflow-auto whitespace-pre-wrap break-all text-xs text-white/60">{{ content }}</pre></details>
</div>
<div v-else-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="eventKind !== undefined" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Event kind</p><p class="text-sm font-medium text-white">{{ eventKind }} <span class="text-white/45">({{ eventKindLabel }})</span></p></div>
</div>
@@ -55,7 +60,7 @@ const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata', 1: 'Short text note', 2: 'Recommend relay', 3: 'Contacts', 4: 'Encrypted DM',
5: 'Event deletion', 6: 'Repost', 7: 'Reaction', 1618: 'Git pull request', 1619: 'Git pull request update',
1621: 'Git issue', 9734: 'Zap request', 9735: 'Zap receipt', 10002: 'Relay list',
30023: 'Long-form content', 30617: 'Git repository announcement',
27236: 'Local Cloud video registration', 27237: 'Recover or retire video registration', 30023: 'Long-form content', 30617: 'Git repository announcement',
}
const METHOD_LABELS: Record<string, string> = {
getPublicKey: 'Share public identity', signEvent: 'Sign Nostr event',
@@ -78,6 +83,17 @@ const methodLabel = computed(() => METHOD_LABELS[props.method] ?? props.method)
const requestTitle = computed(() => props.method === 'getPublicKey' ? 'Share this identity?' : 'Approve this request?')
const progressTitle = computed(() => props.method === 'getPublicKey' ? 'Sharing identity…' : 'Signing locally…')
const successTitle = computed(() => props.method === 'getPublicKey' ? 'Identity shared' : 'Request signed')
const mediaApproval = computed(() => {
if (![27236, 27237].includes(props.eventKind ?? 0) || !props.content) return null
try {
const value = JSON.parse(props.content)
if (!['archipelago.media-registration.approval.v1', 'archipelago.media-registration.resolution.v1'].includes(value.scope)
|| typeof value.intent?.projectId !== 'string' || (props.eventKind === 27236 && typeof value.selection?.cloudFile !== 'string')
|| !Number.isSafeInteger(value.intent.priceSats) || !Number.isSafeInteger(value.intent.viewingSeconds)) return null
return { project: value.intent.projectId, file: value.selection?.cloudFile ?? '', resolving: props.eventKind === 27237,
price: value.intent.priceSats, seconds: value.intent.viewingSeconds }
} catch { return null }
})
const contentPreview = computed(() => !props.content ? '' : props.content.length > 200 ? `${props.content.slice(0, 200)}…` : props.content)
const eventKindLabel = computed(() => props.eventKind === undefined ? '' : EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown')
function approve() { emit('approve', rememberChoice.value) }
@@ -0,0 +1,25 @@
<template>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after first play</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RentalOffer } from '@/composables/useRentalPurchaseBridge'
const props = defineProps<{ request: RentalOffer | null; quote: { wallet_debit_sats: number; network: 'mainnet' | 'testnet'; mint_url: string } | null; phase: string; error: string }>()
const emit = defineEmits<{ review: []; approve: []; cancel: []; cancelUnpaid: [] }>()
const modal = ref<HTMLElement | null>(null)
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
const busy = computed(() => props.phase === 'loading' || props.phase === 'paying')
</script>
@@ -5,7 +5,7 @@ import AppLauncherOverlay from '../AppLauncherOverlay.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
vi.mock('@/stores/appLauncher', async () => {
const { reactive } = await import('vue')
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review' })
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review', setNativeIdentityBusy: vi.fn(), registrationRequest: null, registrationPhase: 'select', registrationError: '', rentalRequest: null, rentalQuote: null, rentalPhase: 'review', rentalError: '' })
return { useAppLauncherStore: () => state }
})
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))