Merge ngit external-access PR 79ca68c1 into combined UAT candidate

Preserve current maintenance/session guards, Firewall UI and existing catalogs.
Retain scoped guest access, publishing journeys and local Blossom integration.
Normalize Blossom/router memory units to supported quadlet suffixes.

Validation: 108 dashboard tests, 10 gateway policy tests, strict source catalog
check. Integrated isolated backend qualification remains required before main.
This commit is contained in:
archipelago
2026-10-08 18:59:24 -04:00
100 changed files with 7479 additions and 111 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="28" fill="#191c28"/><g fill="#dca6c6"><ellipse cx="64" cy="40" rx="17" ry="24"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(72 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(144 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(216 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(288 64 64)"/></g><circle cx="64" cy="64" r="13" fill="#f1cf86"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+25
View File
@@ -715,6 +715,31 @@
"tier": "optional",
"icon": "/assets/img/app-icons/gashboard.svg",
"repoUrl": "https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy"
},
{
"id": "blossom",
"author": "hzrd149 / Archipelago",
"requires": [],
"tier": "optional",
"title": "Blossom",
"version": "6.4.1-archy.2",
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.2",
"category": "data",
"repoUrl": "https://github.com/hzrd149/blossom-server",
"icon": "/assets/img/app-icons/blossom.svg"
},
{
"id": "public-web-router",
"author": "Archipelago",
"requires": [],
"tier": "optional",
"title": "Public Web Router",
"version": "0.1.0",
"description": "Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.",
"dockerImage": "localhost/archipelago-public-web-router:0.1.0",
"category": "networking",
"icon": "/assets/img/app-icons/nginx.svg"
}
]
}
+23 -12
View File
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<BaseModal :manage-history="false" :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
@@ -8,19 +8,22 @@
</div>
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
<p v-if="configured" class="text-sm text-white/70" role="status">Your {{ mode === 'claude' ? 'Claude' : 'OpenAI' }} API key from Settings is ready to use.</p>
<button v-if="configured && !replacingKey" type="button" class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="replacingKey = true">Change API key</button>
<template v-if="status && (!configured || replacingKey)">
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
</template>
<template v-if="mode === 'openai'">
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
</template>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : configured && !key.trim() ? (mode === 'claude' ? 'Use Claude' : 'Use OpenAI') : 'Save and continue' }}</button>
</form>
<div v-else-if="mode === 'routstr'" class="space-y-3">
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Top up with ecash</button>
<RoutstrBudgetSection />
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
@@ -28,14 +31,17 @@
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
</BaseModal>
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
<ReceiveBitcoinModal :manage-history="false" :show="show && funding && !scanning" initial-method="ecash" @close="finishFunding" @received="finishFunding" @scan="scanning = true" />
<WalletScanModal :show="show && scanning" @close="finishScan" @sent="finishScan" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useModalHistory } from '@/composables/useModalHistory'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from './BaseModal.vue'
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
import WalletScanModal from './WalletScanModal.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
type Choice = 'claude' | 'openai' | 'routstr'
@@ -49,9 +55,12 @@ interface ProviderStatus {
}
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
const mode = ref<Choice | null>(null)
const choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routstr · sats' }, { id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }]
// Keep one browser Back entry while switching between connection, funding and scan.
useModalHistory(computed(() => props.show), close)
const mode = ref<Choice | null>('routstr')
const key = ref('')
const replacingKey = ref(false)
const model = ref('')
const status = ref<ProviderStatus | null>(null)
const balance = ref<number | null>(null)
@@ -59,6 +68,7 @@ const error = ref('')
const loading = ref(false)
const saving = ref(false)
const funding = ref(false)
const scanning = ref(false)
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
let refreshPromise: Promise<ProviderStatus | null> | null = null
async function refresh(): Promise<ProviderStatus | null> {
@@ -95,8 +105,8 @@ async function refreshBalance() {
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
catch { balance.value = null }
}
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; emit('close') }
function choose(choice: Choice) { key.value = ''; replacingKey.value = false; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; scanning.value = false; emit('close') }
async function save() {
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
const provider = mode.value
@@ -128,8 +138,9 @@ async function continueRoutstr() {
emit('configured', 'routstr'); close()
} catch { error.value = 'Could not select Routstr. Try again.' }
}
async function finishScan() { scanning.value = false; await refreshBalance() }
async function finishFunding() { funding.value = false; await refreshBalance() }
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; replacingKey.value = false; funding.value = false; scanning.value = false } })
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
</script>
+4 -1
View File
@@ -62,10 +62,13 @@ const props = withDefaults(defineProps<{
maxWidth?: string
zIndex?: string
contentClass?: string
/** A parent flow may own one history entry across several modal panels. */
manageHistory?: boolean
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
manageHistory: true,
})
const emit = defineEmits<{
@@ -109,7 +112,7 @@ useBodyScrollLock(computed(() => props.show))
// Browser/mouse/gesture Back closes the modal instead of navigating the
// router out from under it — the native-app behaviour kiosk and mobile
// browsers expect (the companion webview already provides it natively).
useModalHistory(computed(() => props.show), close)
useModalHistory(computed(() => props.show && props.manageHistory), close)
</script>
<style scoped>
+3 -1
View File
@@ -6,7 +6,7 @@
<RouterLink
v-for="(goal, idx) in goals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
:to="goal.route || `/dashboard/goals/${goal.id}`"
class="goal-card glass-card p-6 block"
:class="{ 'home-card-animate': animate }"
:style="{ '--card-stagger': idx }"
@@ -85,6 +85,8 @@ function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
globe: '🌐',
website: '📝',
shop: '🏪',
payments: '⚡',
photos: '📸',
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<BaseModal :manage-history="manageHistory !== false" :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -111,6 +111,7 @@
<!-- Completion is deliberately its own modal, matching the Lightning
payment moment. It is not an inline status inside the receive form. -->
<BaseModal
:manage-history="manageHistory !== false"
:show="show && !!receiveSuccess"
title="Payment received"
max-width="max-w-2xl"
@@ -147,6 +148,7 @@ const lightning = useLightningRequired()
const props = defineProps<{
show: boolean
manageHistory?: boolean
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
const selected = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const trigger = ref<HTMLButtonElement>()
const search = ref<HTMLInputElement>()
const open = ref(false)
const query = ref('')
const active = ref(0)
const uid = useId()
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
watch(query, () => { active.value = 0 })
watch(() => props.disabled, value => { if (value) open.value = false })
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
async function keyboard(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
if (!open.value) {
if (event.key === 'Enter') return // native button click opens it
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
}
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
if (event.key === 'Enter') return
event.preventDefault()
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
search.value?.focus()
await nextTick()
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
}
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
</script>
<template>
<div ref="root" class="min-w-0" @keydown="keyboard">
<span :id="`${uid}-label`" class="block mb-2">Application</span>
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</button>
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
</button>
</div>
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
export interface SetupStep { id: string; title: string; description: string; complete: boolean }
const props = defineProps<{ steps: SetupStep[]; busy?: boolean; continueLabel?: string; continueDisabled?: boolean }>()
const emit = defineEmits<{ next: [step: string] }>()
const active = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const index = computed(() => Math.max(0, props.steps.findIndex(step => step.id === active.value)))
const completed = computed(() => props.steps.filter(step => step.complete).length)
watch(() => props.steps, steps => {
if (!steps.some(step => step.id === active.value)) active.value = steps.find(step => !step.complete)?.id ?? steps[0]?.id ?? ''
})
watch(active, async () => {
await nextTick()
const heading = root.value?.querySelector<HTMLButtonElement>('button[aria-expanded="true"]')
heading?.focus({ preventScroll: true })
heading?.scrollIntoView?.({ block: 'nearest', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' })
})
function move(offset: number) {
const step = props.steps[index.value + offset]
if (step) active.value = step.id
}
</script>
<template>
<div ref="root">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/60">Step {{ index + 1 }} of {{ steps.length }}</span>
<span class="goal-status-badge goal-status-badge-in-progress">In progress</span>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden" role="progressbar" aria-label="Saved setup steps" :aria-valuenow="completed" :aria-valuemax="steps.length" aria-valuemin="0">
<div class="h-full rounded-full bg-orange-400 transition-all duration-500 ease-out" :style="{ width: `${(completed / steps.length) * 100}%` }" />
</div>
</div>
<div class="space-y-3">
<section v-for="(step, stepIndex) in steps" :key="step.id" class="glass-card p-0 overflow-hidden">
<div class="goal-step" :class="{ 'goal-step-completed': step.complete, 'goal-step-active': active === step.id, 'goal-step-pending': stepIndex > index && !step.complete }">
<button type="button" class="flex items-start gap-4 w-full text-left" :disabled="busy" :aria-expanded="active === step.id" :aria-controls="`setup-step-${step.id}`" @click="active = step.id">
<span class="mt-0.5 shrink-0 w-6 h-6 rounded-full flex items-center justify-center" :class="step.complete ? 'bg-green-500/20' : 'bg-white/10'">
<svg v-if="step.complete" class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
<span v-else class="text-xs text-white/40 font-medium">{{ stepIndex + 1 }}</span>
</span>
<span class="flex-1 min-w-0"><span class="block text-base font-semibold text-white/90 mb-1">{{ step.title }}</span><span class="block text-sm text-white/55 leading-relaxed">{{ step.description }}</span></span>
</button>
<div v-if="active === step.id" :id="`setup-step-${step.id}`" class="mt-5 sm:ml-10">
<fieldset :disabled="busy" class="space-y-8 min-w-0"><slot :name="step.id" /></fieldset>
<div class="flex flex-wrap items-center gap-3 mt-8 pt-6 border-t border-white/10">
<button v-if="stepIndex > 0" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="move(-1)">Back</button>
<button v-if="stepIndex < steps.length - 1" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || continueDisabled" @click="emit('next', steps[stepIndex + 1]!.id)">{{ continueLabel || (step.id === 'storage' ? 'Continue without installing' : 'Continue') }}<span aria-hidden="true">→</span></button>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.goal-step-pending { opacity: .72; }
button:focus-visible { outline: 2px solid #fb923c; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .transition-all { transition: none; } }
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { websitePreview } from '@/services/publishing'
const props = defineProps<{
publication: { html: string; public_archive?: string | null }
archive?: { sha256: string; size: number } | null
address?: string | null
busy?: boolean
}>()
const emit = defineEmits<{ change: [enabled: boolean] }>()
const approved = ref(false)
watch(() => [props.publication, props.archive], () => { approved.value = false })
const fileAddress = computed(() => props.address && props.publication.public_archive
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
</script>
<template>
<details class="rounded-xl border border-white/10 p-4">
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
<div class="space-y-4 pt-4">
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
<template v-if="publication.public_archive">
<p class="text-sm">This snapshot is shared on this connection.</p>
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
</template>
<template v-else-if="archive">
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
</template>
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
</div>
</details>
</template>
@@ -1,15 +1,49 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AIConnectionModal from '../AIConnectionModal.vue'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import WalletScanModal from '../WalletScanModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
vi.mock('../WalletScanModal.vue', () => ({ default: { props: ['show'], template: '<div />' } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod', 'manageHistory'], template: '<div />' } }))
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
describe('AI connection setup', () => {
it('recognizes the existing Claude key and selects it without reading or rewriting the secret', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: { ...state(), claude_configured: true } } : {})
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
await button(w, 'Claude API').trigger('click')
expect(w.text()).toContain('Your Claude API key from Settings is ready to use.')
expect(w.find('#ai-connection-key').exists()).toBe(false)
expect(button(w, 'Use Claude').attributes('disabled')).toBeUndefined()
await w.get('form').trigger('submit'); await flushPromises()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
expect(writes.map(r => r.params)).toEqual([{ key: 'ai_provider', value: JSON.stringify({ provider: 'claude', openai_model: '' }) }])
expect(w.emitted('configured')).toEqual([['claude', undefined]])
await button(w, 'Change API key').trigger('click')
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
await w.setProps({ show: false }); w.unmount()
})
it('opens on Routstr with a top-up action without authorizing spending', async () => {
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
expect(button(w, 'Routstr · sats').attributes('aria-pressed')).toBe('true')
expect(button(w, 'Top up with ecash').exists()).toBe(true)
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
await button(w, 'Top up with ecash').trigger('click')
expect(w.findComponent(ReceiveBitcoinModal).props()).toMatchObject({ show: true, initialMethod: 'ecash', manageHistory: false })
w.findComponent(ReceiveBitcoinModal).vm.$emit('scan'); await flushPromises()
expect(w.findComponent(WalletScanModal).props('show')).toBe(true)
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(false)
w.findComponent(WalletScanModal).vm.$emit('close'); await flushPromises()
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(true)
w.unmount()
})
it('detects absent configuration without treating a failed status query as missing keys', async () => {
const w = mountModal()
expect(await (w.vm as any).checkNeeded()).toBe(true)
@@ -26,7 +60,7 @@ describe('AI connection setup', () => {
await w.get('form').trigger('submit'); await flushPromises()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
expect(w.find('#ai-connection-key').exists()).toBe(false)
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
w.unmount()
@@ -1,9 +1,20 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
it('lets a parent flow own history while panels change', async () => {
const push = vi.spyOn(window.history, 'pushState')
const back = vi.spyOn(window.history, 'back')
const wrapper = mount(BaseModal, { props: { show: false, title: 'Panel', manageHistory: false } })
await wrapper.setProps({ show: true })
await wrapper.setProps({ show: false })
expect(push).not.toHaveBeenCalled()
expect(back).not.toHaveBeenCalled()
wrapper.unmount(); push.mockRestore(); back.mockRestore()
})
afterEach(() => {
document.body.style.overflow = ''
})
@@ -0,0 +1,29 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchableAppSelect from '../SearchableAppSelect.vue'
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
describe('searchable app picker', () => {
it('renders actual touchable options, filters them and selects only an offered app', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
await wrapper.get('input').setValue('imm')
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
await wrapper.get('[role="option"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
await wrapper.get('input').setValue('home')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
await wrapper.get('button').trigger('click')
await wrapper.get('input').setValue('unknown')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
})
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
describe('archived website sharing consent', () => {
it('requires fresh approval after the reviewed snapshot changes', async () => {
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[true]])
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.emitted('change')).toHaveLength(1)
})
it('keeps removal available without new approval and exposes only the selected hash', async () => {
const hash = 'a'.repeat(64)
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
expect(wrapper.text()).toContain('https://example.com/'+hash)
expect(wrapper.find('input').exists()).toBe(false)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[false]])
})
})
+14 -28
View File
@@ -60,6 +60,20 @@ export const GOALS: GoalDefinition[] = [
estimatedTime: '~5–10 min',
difficulty: 'beginner',
},
{
id: 'external-access', title: 'Allow external connections',
subtitle: 'Choose FIPS, public web and Tor access for your services',
icon: 'globe', category: 'network', requiredApps: [],
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
},
{
id: 'publish-website', title: 'Publish a website',
subtitle: 'Create a website on your node and choose where to publish it',
icon: 'website', category: 'community', requiredApps: [],
route: '/dashboard/setup/website', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'publish-website', title: 'Create and publish', description: 'Preview your website and reuse existing connections.', action: 'configure', isAutomatic: false }],
},
{
id: 'open-a-shop',
title: 'Open a Shop',
@@ -154,34 +168,6 @@ export const GOALS: GoalDefinition[] = [
estimatedTime: '~30 min + sync time',
difficulty: 'beginner',
},
{
id: 'file-browser',
title: 'File Browser',
subtitle: 'Browse, upload, and manage files on your server',
icon: 'files',
category: 'storage',
requiredApps: ['filebrowser'],
steps: [
{
id: 'install-filebrowser',
title: 'Install FileBrowser',
description: 'FileBrowser is a lightweight web file manager. Upload, download, and organize files on your server from any browser.',
appId: 'filebrowser',
action: 'install',
isAutomatic: true,
},
{
id: 'configure-filebrowser',
title: 'Log In',
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
appId: 'filebrowser',
action: 'configure',
isAutomatic: false,
},
],
estimatedTime: '~5 min',
difficulty: 'beginner',
},
{
id: 'store-files',
title: 'Store My Files',
+12
View File
@@ -82,6 +82,18 @@ export const helpTree: HelpSection[] = [
content: 'Share files and media with connected peers through the Content section in Web5. Add content from your Cloud storage, set it as free or paid (ecash-gated), and connected peers can browse and access your catalog. For paid content, peers pay with ecash micropayments — the sats appear in your wallet instantly.',
relatedPath: '/dashboard/web5',
},
{
id: 'external-access-guide',
label: 'Allowing External Connections',
content: 'Open Setup → Allow external connections. Choose how visitors will connect: FIPS, an HTTPS domain through your own gateway, or Tor. You can select more than one. Save your choices, then choose a supported app and create an expiring guest token. Share that token privately. Guests cannot use it to sign in to your dashboard. Check the app address from the visitor’s device; saved settings alone do not confirm a working connection.',
relatedPath: '/dashboard/setup/external-access',
},
{
id: 'website-guide',
label: 'Publishing a Website',
content: 'Open Setup → Publish a website. The guide reuses your saved connections and skips Blossom installation when it is already installed. Create a page with AIUI, use Continue to website setup on its HTML preview, then review and save the draft. You can keep a signed copy in local Blossom. Choose an address and explicitly publish each connection. For Nostr, review the exact page, profile identity, storage server and relays before sharing: public copies may remain even after a removal request. FIPS and Tor do not require a purchased domain.',
relatedPath: '/dashboard/setup/website',
},
{
id: 'self-hosting',
label: 'Self-Hosting',
+10
View File
@@ -245,6 +245,16 @@ const router = createRouter({
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{
path: 'setup/external-access',
name: 'external-access',
component: () => import('@/views/publishing/PublishingSetup.vue'),
},
{
path: 'setup/website',
name: 'publish-website',
component: () => import('@/views/publishing/PublishingSetup.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, type Ref } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/router', () => ({ default: { push: vi.fn() } }))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
@@ -22,6 +24,8 @@ import { ContextBroker } from '../contextBroker'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { rpcClient } from '@/api/rpc-client'
import { fileBrowserClient } from '@/api/filebrowser-client'
import router from '@/router'
import { pendingWebsiteHtml } from '../websiteImport'
describe('ContextBroker', () => {
let broker: ContextBroker
@@ -31,6 +35,7 @@ describe('ContextBroker', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
pendingWebsiteHtml.value = null
mockPostMessage = vi.fn()
iframeRef = ref<HTMLIFrameElement | null>({
@@ -46,6 +51,23 @@ describe('ContextBroker', () => {
expect(broker).toBeDefined()
})
it('only accepts website drafts from the registered AIUI frame and origin', async () => {
const receive = (origin: string, source: MessageEventSource | null) => {
;(broker as unknown as { handleMessage(event: MessageEvent): void }).handleMessage(new MessageEvent('message', {
origin, source, data: { type: 'action:request', id: 'website-draft', action: 'prepare-website', params: { html: '<h1>Draft</h1>' } },
}))
}
receive('https://untrusted.example', iframeRef.value!.contentWindow)
receive('http://localhost:8100', window)
expect(pendingWebsiteHtml.value).toBeNull()
expect(router.push).not.toHaveBeenCalled()
receive('http://localhost:8100', iframeRef.value!.contentWindow)
await vi.waitFor(() => expect(router.push).toHaveBeenCalledWith('/dashboard/setup/website'))
expect(pendingWebsiteHtml.value).toBe('<h1>Draft</h1>')
expect(rpcClient.call).not.toHaveBeenCalled()
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'action:response', id: 'website-draft', success: true }), expect.any(String))
})
it('start registers message listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
broker.start()
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import { installPublishingApp } from '../installPublishingApp'
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
describe('Setup catalogue installation', () => {
it('supplies the signed build tag and version required by package.install', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { 'public-web-router': { version: '0.1.0', manifest: { app: { id: 'public-web-router', container: { build: { tag: 'localhost/archipelago-public-web-router:0.1.0' } } } } } } }) }))
await installPublishingApp('public-web-router')
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'package.install', params: { id: 'public-web-router', dockerImage: 'localhost/archipelago-public-web-router:0.1.0', version: '0.1.0' }, timeout: 600000, maxRetries: 0 })
})
it('does not install from an unavailable or mismatched catalogue', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(installPublishingApp('blossom')).rejects.toThrow('unavailable')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { blossom: { version: '1', image: 'localhost/test:1', manifest: { app: { id: 'other' } } } } }) }))
await expect(installPublishingApp('blossom')).rejects.toThrow('not available')
expect(rpcClient.call).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../publishing', () => ({ publishing: { status: vi.fn(), update: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import { publishing } from '../publishing'
import { namedNsiteUrl, prepareNsite, publishNsite, relayAddresses, retryNsite, requestNsiteDeletion, nsiteIdentities, storeLocalWebsite } from '../nsitePublishing'
import type { NsiteReceipt, SignedNsiteEvent } from '../nsitePublishing'
const identity = { id: 'profile', name: 'Me', nostr_pubkey: 'a'.repeat(64), is_node: false }
const event: SignedNsiteEvent = { id: 'b'.repeat(64), pubkey: identity.nostr_pubkey, kind: 35128, created_at: 1, tags: [['d', 'website123']], content: '', sig: 'c'.repeat(128) }
const receipt: NsiteReceipt = { identity_id: identity.id, server: 'https://blossom.example', event, accepted_relays: [], deletion_requested: false }
let accept = true
class Socket {
onopen?: () => void
onmessage?: (event: { data: string }) => void
onerror?: () => void
onclose?: () => void
constructor() { queueMicrotask(() => this.onopen?.()) }
send(raw: string) {
const sent = JSON.parse(raw)[1]
queueMicrotask(() => {
this.onmessage?.({ data: JSON.stringify(['OK', 'unrelated-id', true]) })
this.onmessage?.({ data: JSON.stringify(['OK', sent.id, accept]) })
})
}
close() {}
}
const prepared = { html: '<h1>Hello 🏝</h1>', sha256: 'd'.repeat(64), server: receipt.server, identifier: 'website123', authorization: { kind: 24242, tags: [['expiration', String(Math.floor(Date.now() / 1000) + 300)]] }, manifest: { ...event } }
async function publishReviewed(html: string) {
const p = await prepareNsite('project', 4, receipt.server, html)
return publishNsite('project', 4, identity, ['wss://relay.example'], p)
}
beforeEach(() => {
vi.clearAllMocks(); accept = true
vi.stubGlobal('WebSocket', Socket)
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: {} } } as never)
vi.mocked(publishing.update).mockResolvedValue({} as never)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.nsite-prepare') return prepared as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'identity.list') return { identities: [identity, { ...identity, id: 'node', is_node: true }] } as never
throw new Error('Unexpected RPC')
})
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html)))
})
describe('named nsite publishing', () => {
it('publishes local Blossom bytes through the node adapter and reads the public hash before announcing', async () => {
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async request => request.method === 'publishing.blossom-store' ? {} as never : original(request))
vi.mocked(fetch).mockReset().mockResolvedValue(new Response(prepared.html))
await publishNsite('project', 4, identity, ['wss://relay.example'], { ...prepared, local: true })
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'publishing.blossom-store', params: expect.objectContaining({ nsite: { html: prepared.html, server: prepared.server, acknowledge_public: true } }) }))
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith(`${prepared.server}/${prepared.sha256}`, expect.objectContaining({ credentials: 'omit', redirect: 'error' }))
expect(publishing.update).toHaveBeenCalledTimes(2)
})
it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => {
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.blossom-prepare') return { authorization: prepared.authorization } as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'publishing.blossom-store') return {} as never
throw new Error('Unexpected RPC')
})
await storeLocalWebsite('project', 4, identity)
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.blossom-prepare', 'identity.nostr-sign', 'publishing.blossom-store'])
expect(fetch).not.toHaveBeenCalled()
expect(socket).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
await expect(storeLocalWebsite('project', 4, { ...identity, is_node: true })).rejects.toThrow('profile identity')
})
it('uses profile identities and rejects insecure relay URLs', async () => {
expect(await nsiteIdentities()).toEqual([identity])
expect(relayAddresses('wss://relay.example wss://relay.example')).toEqual(['wss://relay.example/'])
for (const value of ['ws://relay.example', 'wss://user:secret@relay.example', 'wss://relay.example/#key']) expect(() => relayAddresses(value)).toThrow()
})
it('excludes legacy node identities before any local upload signing', async () => {
const hidden = [
{ ...identity, id: ' Node-legacy ', is_node: false },
{ ...identity, name: '\uFEFFNode ', is_node: false },
{ ...identity, nostr_pubkey: 'invalid' },
]
vi.mocked(rpcClient.call).mockResolvedValueOnce({ identities: [identity, ...hidden] } as never)
expect(await nsiteIdentities()).toEqual([identity])
vi.mocked(rpcClient.call).mockClear()
for (const candidate of hidden) await expect(storeLocalWebsite('project', 4, candidate)).rejects.toThrow('profile identity')
expect(rpcClient.call).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
})
it('preparation never signs, uploads or broadcasts', async () => {
await prepareNsite('project', 4, receipt.server, '<h1>Private draft</h1>')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.nsite-prepare'])
})
it('refuses stale reviews before signing or uploading', async () => {
await expect(publishNsite('project', 3, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed after review')
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('uploads exact UTF-8 bytes, scopes signing to the chosen profile, and records delivery', async () => {
const result = await publishReviewed( '<meta http-equiv="refresh" content="0;url=https://tracker.example"><script>bad()</script><h1>Draft</h1>')
expect(result.accepted_relays).toEqual(['wss://relay.example'])
const prep = vi.mocked(rpcClient.call).mock.calls[0]![0].params as { html: string }
expect(prep.html).not.toContain('<script')
expect(prep.html).not.toContain('http-equiv')
expect(prep.html).toContain('<h1>Draft</h1>')
expect(fetch).toHaveBeenNthCalledWith(1, `${receipt.server}/upload`, expect.objectContaining({ method: 'PUT', credentials: 'omit', redirect: 'error', body: prepared.html }))
expect(publishing.update).toHaveBeenCalledTimes(2)
expect(vi.mocked(publishing.update).mock.calls[0]![1]).toMatchObject({ receipt: { accepted_relays: [] } })
expect(vi.mocked(publishing.update).mock.calls[1]![1]).toMatchObject({ receipt: { accepted_relays: ['wss://relay.example'] } })
const signs = vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'identity.nostr-sign')
expect(signs.every(([r]) => r.params?.id === identity.id)).toBe(true)
})
it('never pays or announces when storage requires payment', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response('', { status: 402 }))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No payment was made')
expect(publishing.update).not.toHaveBeenCalled()
})
it('rejects altered signer output before upload or relay delivery', async () => {
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'identity.nostr-sign') return { ...event, ...prepared.authorization, tags: [['t', 'upload']] } as never
throw new Error('Unexpected RPC')
})
await expect(publishNsite('project', 4, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed the reviewed event')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
})
it('bounds untrusted upload receipts before announcing', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response(' '.repeat(8193)))
await expect(publishReviewed('<h1>Draft</h1>')).rejects.toThrow('size limit')
expect(publishing.update).not.toHaveBeenCalled()
})
it('does not announce if the server changes uploaded bytes', async () => {
vi.mocked(fetch).mockReset().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }))).mockResolvedValueOnce(new Response('different'))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('different website bytes')
expect(publishing.update).not.toHaveBeenCalled()
})
it('retains a pending manifest on rejection and retries without signing or uploading', async () => {
accept = false
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No relay accepted')
vi.clearAllMocks(); accept = true
const result = await retryNsite('project', receipt, ['wss://relay.example'])
expect(result.accepted_relays).toHaveLength(1)
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('does not retry announcements after local file sharing is revoked', async () => {
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: { project: { domain: { hostname: 'blossom.example' }, fips_publication: {} } } } } as never)
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
await expect(retryNsite('project', receipt, ['wss://relay.example'])).rejects.toThrow('no longer shared')
expect(socket).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
})
it('requests deletion with the publishing identity without deleting shared blobs', async () => {
await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example'])
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } }))
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).toHaveBeenCalledWith(4, expect.objectContaining({ receipt: expect.objectContaining({ deletion_requested: true }) }))
})
it('builds a portable named-site gateway URL without overwriting the root site', () => {
const url = new URL(namedNsiteUrl(receipt, 'https://gateway.example'))
expect(url.hostname.split('.')[0]).toHaveLength(50 + 'website123'.length)
expect(url.hostname).toContain('website123.gateway.example')
expect(() => namedNsiteUrl(receipt, 'http://gateway.example')).toThrow()
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { PUBLISH_ROUTES, publishing, websitePreview } from '../publishing'
import { rpcClient } from '@/api/rpc-client'
describe('publishing trust boundaries', () => {
it('places restrictive CSP before untrusted website content', () => {
const hostile = '<script>fetch("/rpc")</script><meta http-equiv="Content-Security-Policy" content="default-src *">'
const preview = websitePreview(hostile)
expect(preview.indexOf("default-src 'none'")).toBeLessThan(preview.indexOf(hostile))
expect(preview).toContain("form-action 'none'")
expect(preview).not.toContain("script-src 'unsafe-inline'")
})
it('supports all four routes without Tailscale', () => {
expect(PUBLISH_ROUTES.map(r => r.id)).toEqual(['fips', 'public-web', 'tor', 'nostr'])
})
it('does not retry ambiguous writes and carries the node version', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ state: { version: 8 }, project_id: null })
await publishing.update(7, { action: 'connections', routes: ['fips', 'tor'] })
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.update', params: { version: 7, change: { action: 'connections', routes: ['fips', 'tor'] } }, maxRetries: 0 })
})
})
@@ -0,0 +1,14 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { pendingWebsiteHtml, prepareWebsiteImport } from '../websiteImport'
beforeEach(() => { pendingWebsiteHtml.value = null })
describe('AIUI website handoff', () => {
it('holds HTML only in memory for explicit import', () => {
expect(prepareWebsiteImport('<h1>My page</h1>')).toBe(true)
expect(pendingWebsiteHtml.value).toBe('<h1>My page</h1>')
})
it('rejects invalid or oversized content without destroying a pending draft', () => {
prepareWebsiteImport('existing')
for (const bad of [null, {}, '', '\0', 'a'.repeat(512 * 1024 + 1)]) expect(prepareWebsiteImport(bad)).toBe(false)
expect(pendingWebsiteHtml.value).toBe('existing')
})
})
+10
View File
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { prepareWebsiteImport } from '@/services/websiteImport'
import type {
AIUIRequest,
ArchyResponse,
@@ -237,6 +238,7 @@ export class ContextBroker {
this.handleContextRequest(msg.id, msg.category, msg.query)
break
case 'action:request':
if (msg.action === 'prepare-website' && event.source !== this.iframe.value?.contentWindow) return
this.handleActionRequest(msg.id, msg.action, msg.params)
break
case 'theme:request':
@@ -640,6 +642,14 @@ export class ContextBroker {
try {
switch (action) {
case 'prepare-website':
if (prepareWebsiteImport(params?.html)) {
void import('@/router').then(({ default: router }) => router.push('/dashboard/setup/website'))
success = true
} else {
error = 'Provide a nonempty HTML draft up to 512 KiB'
}
break
case 'navigate':
if (params.path) {
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
@@ -0,0 +1,14 @@
import { rpcClient } from '@/api/rpc-client'
import type { SignedAppCatalog } from '@/views/discover/curatedApps'
/** Setup uses the same verified catalogue and package installer as Apps. */
export async function installPublishingApp(id: 'blossom' | 'public-web-router') {
const response = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error('The trusted app catalogue is unavailable. Try again from Apps.')
const catalog = await response.json() as SignedAppCatalog
const entry = catalog.apps?.[id]
const app = entry?.manifest?.app
const dockerImage = entry?.image || app?.container?.image || app?.container?.build?.tag
if (!entry?.version || app?.id !== id || !dockerImage) throw new Error('This app is not available in the trusted catalogue yet.')
return rpcClient.call({ method: 'package.install', params: { id, dockerImage, version: entry.version }, timeout: 600000, maxRetries: 0 })
}
+174
View File
@@ -0,0 +1,174 @@
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { publishing } from './publishing'
export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string }
export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean }
export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
export interface PreparedNsite { local?: boolean; html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
// Match the platform app signer and its derived Blossom upload allowlist,
// including older node records that do not carry the explicit is_node flag.
function isProfileIdentity(identity: NsiteIdentity): boolean {
return !identity.is_node && !identity.id.trim().toLowerCase().startsWith('node-')
&& identity.name.trim().toLowerCase() !== 'node' && /^[a-f0-9]{64}$/.test(identity.nostr_pubkey)
}
export function relayAddresses(raw: string): string[] {
const list = [...new Set(raw.split(/[\s,]+/).filter(Boolean).map(value => {
const url = new URL(value)
if (url.protocol !== 'wss:' || url.username || url.password || url.hash || value.length > 300) throw new Error('Use secure wss:// relay URLs without credentials or fragments')
return url.href
}))]
if (!list.length || list.length > 8) throw new Error('Choose between one and eight relays')
return list
}
export async function nsiteIdentities(): Promise<NsiteIdentity[]> {
const data = await rpcClient.call<{ identities: NsiteIdentity[] }>({ method: 'identity.list', maxRetries: 0 })
return data.identities.filter(isProfileIdentity)
}
async function sign(identity: NsiteIdentity, event: Record<string, unknown>): Promise<SignedNsiteEvent> {
if (!isProfileIdentity(identity)) throw new Error('Choose a profile identity for website files')
const signed = await rpcClient.call<SignedNsiteEvent>({ method: 'identity.nostr-sign', params: { id: identity.id, event }, maxRetries: 0 })
if (signed.pubkey !== identity.nostr_pubkey || signed.kind !== event.kind) throw new Error('Signer returned a different identity or event kind')
for (const key of ['created_at', 'content', 'tags'] as const) {
if (event[key] !== undefined && JSON.stringify(signed[key]) !== JSON.stringify(event[key])) throw new Error('Signer changed the reviewed event; this event will not be sent')
}
return signed
}
export async function storeLocalWebsite(projectId: string, version: number, identity: NsiteIdentity): Promise<void> {
if (!isProfileIdentity(identity)) throw new Error('Choose a profile identity for local files')
const prepared = await rpcClient.call<{ authorization: Record<string, unknown> }>({ method: 'publishing.blossom-prepare', params: { id: projectId, version }, maxRetries: 0 })
const authorization = await sign(identity, prepared.authorization)
await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization }, timeout: 70000, maxRetries: 0 })
}
export function sendToRelay(url: string, event: SignedNsiteEvent): Promise<boolean> {
return new Promise(resolve => {
let socket: WebSocket
try { socket = new WebSocket(url) } catch { resolve(false); return }
let settled = false
const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.close(); resolve(ok) }
const timer = setTimeout(() => finish(false), 15000)
socket.onopen = () => socket.send(JSON.stringify(['EVENT', event]))
socket.onerror = () => finish(false)
socket.onclose = () => finish(false)
socket.onmessage = message => {
if (typeof message.data !== 'string' || message.data.length > 65536) return
try {
const reply = JSON.parse(message.data)
if (reply[0] === 'OK' && reply[1] === event.id) finish(reply[2] === true)
} catch { /* Ignore unrelated relay messages. */ }
}
})
}
async function broadcast(event: SignedNsiteEvent, relays: string[]): Promise<string[]> {
const result = await Promise.all(relays.map(async relay => ({ relay, ok: await sendToRelay(relay, event) })))
return result.filter(r => r.ok).map(r => r.relay)
}
async function record(projectId: string, receipt: NsiteReceipt): Promise<void> {
// Persist this operation's receipt without overwriting a newer draft or other
// transport. Only retry the optimistic conflict, never upload/sign/broadcast.
for (let attempt = 0; attempt < 3; attempt++) {
const status = await publishing.status()
try { await publishing.update(status.state.version, { action: 'record-nsite', id: projectId, receipt }); return }
catch (error) {
if (attempt === 2 || !(error instanceof Error) || !error.message.includes('changed')) throw error
}
}
}
async function readback(response: Response, expected: Uint8Array): Promise<void> {
if (!response.ok || !response.body) throw new Error('Uploaded website could not be fetched back')
const reader = response.body.getReader()
let offset = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (offset + value.length > expected.length || value.some((byte, index) => byte !== expected[offset + index])) throw new Error('Blossom returned different website bytes')
offset += value.length
}
if (offset !== expected.length) throw new Error('Blossom returned an incomplete website')
} finally { await reader.cancel() }
}
async function uploadDescriptor(response: Response): Promise<{ sha256: string; size: number }> {
if (!response.body) throw new Error('Blossom upload receipt is empty')
const reader = response.body.getReader()
const bytes = new Uint8Array(8192)
let size = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (size + value.length > bytes.length) throw new Error('Blossom upload receipt exceeds the size limit')
bytes.set(value, size); size += value.length
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)))
} finally { await reader.cancel() }
}
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string, local = false): Promise<PreparedNsite> {
return await rpcClient.call<PreparedNsite>({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, local, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 })
}
export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise<NsiteReceipt> {
if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity')
const current = await publishing.status()
if (current.state.version !== version) throw new Error('The project changed after review. Review the publication again.')
const expiry = (p.authorization.tags as string[][] | undefined)?.find(t => t[0] === 'expiration')?.[1]
if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.')
const authorization = await sign(identity, p.authorization)
const bytes = new TextEncoder().encode(p.html)
if (p.local) {
await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization,
nsite: { html: p.html, server: p.server, acknowledge_public: true } }, timeout: 70000, maxRetries: 0 })
} else {
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization))))
const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html })
if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.')
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
const descriptor = await uploadDescriptor(uploaded)
if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.')
}
await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes)
const event = await sign(identity, p.manifest)
const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false }
// Save the exact signed manifest before network delivery, for recovery.
await record(projectId, receipt)
const delivered = { ...receipt, accepted_relays: await broadcast(event, relays) }
await record(projectId, delivered)
if (!delivered.accepted_relays.length) throw new Error('No relay accepted the manifest. The upload and signed manifest were retained; retry delivery from this project.')
return delivered
}
export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise<NsiteReceipt> {
if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request')
const status = await publishing.status()
const project = status.state.projects[projectId]
// For this node's origin, revoking the public asset must also stop retries.
// An external server is outside the node's control and retains its own copy.
if (project?.domain && receipt.server === `https://${project.domain.hostname}`) {
const asset = project.fips_publication?.nsite_asset
const digest = receipt.event.tags.find(t => t[0] === 'path' && t[1] === '/index.html')?.[2]
if (!asset || asset.receipt.sha256 !== digest) throw new Error('The local nsite file is no longer shared. Review and publish it again first.')
await readback(await fetch(`${receipt.server}/${digest}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), new TextEncoder().encode(asset.html))
}
const accepted = await broadcast(receipt.event, relays)
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
await record(projectId, next)
if (!accepted.length) throw new Error('No relay accepted this delivery attempt')
return next
}
export async function requestNsiteDeletion(projectId: string, receipt: NsiteReceipt, identity: NsiteIdentity, relays: string[]): Promise<void> {
if (identity.id !== receipt.identity_id || identity.nostr_pubkey !== receipt.event.pubkey) throw new Error('Choose the identity that published this nsite')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1]
if (!identifier) throw new Error('Missing named-site identifier')
const event = await sign(identity, { kind: 5, created_at: Math.floor(Date.now() / 1000), content: 'Remove this website manifest', tags: [['e', receipt.event.id], ['a', `35128:${receipt.event.pubkey}:${identifier}`], ['k', '35128']] })
const accepted = await broadcast(event, [...new Set([...receipt.accepted_relays, ...relays])])
if (!accepted.length) throw new Error('No relay accepted the deletion request. The nsite may remain available.')
await record(projectId, { ...receipt, deletion_requested: true })
}
export function namedNsiteUrl(receipt: NsiteReceipt, gateway: string): string {
const url = new URL(gateway)
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.pathname !== '/') throw new Error('Enter the gateway HTTPS origin without a path')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1] ?? ''
if (!/^[a-z0-9-]{1,13}$/.test(identifier) || identifier.endsWith('-') || !/^[a-f0-9]{64}$/.test(receipt.event.pubkey)) throw new Error('Invalid named nsite')
const author = BigInt(`0x${receipt.event.pubkey}`).toString(36).padStart(50, '0')
return `https://${author}${identifier}.${url.hostname}/`
}
+55
View File
@@ -0,0 +1,55 @@
import { rpcClient } from '@/api/rpc-client'
import type { NsiteReceipt } from './nsitePublishing'
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
export interface PublishDomain { hostname: string; destination: string | null }
export interface WebsiteRevision { id: string; created_at: string; html: string }
export interface WebsiteProject {
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null
nsite_receipt?: NsiteReceipt | null
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
}
export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
}
export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
gateway?: { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string }
nostr_relays?: string[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[]
grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[]
}
export interface DnsPlan {
records: { record_type: string; name: string; value: string; ttl: number }[]
verified: boolean; notes: string[]; instructions_url: string
}
export interface HttpsCheck { hostname: string; sha256: string; checked_at: string }
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
{ id: 'fips', title: 'FIPS network', description: 'Use your node’s FIPS address without a public gateway.' },
{ id: 'public-web', title: 'Public web', description: 'Use a domain and a reverse proxy you control.' },
{ id: 'tor', title: 'Tor', description: 'Your node controls its onion address and keeps it when you unpublish.' },
{ id: 'nostr', title: 'Nostr / nsites', description: 'Share a signed static copy through Nostr and Blossom.' },
]
export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
verifyHttps: (id: string, version: number) => rpcClient.call<HttpsCheck>({ method: 'publishing.verify-https', params: { id, version }, timeout: 30000, maxRetries: 0 }),
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
method: 'publishing.update', params: { version, change }, maxRetries: 0,
}),
dns: (domain: PublishDomain) => rpcClient.call<DnsPlan>({ method: 'publishing.dns', params: { ...domain }, maxRetries: 0 }),
generate: (prompt: string, model: string) => rpcClient.call<{html: string; provider: string}>({
method: 'publishing.generate', params: { prompt, model }, timeout: 150000, maxRetries: 0,
}),
}
// This document is also sandboxed with no allow-* tokens by its iframe. The CSP
// precedes model content and cannot be relaxed by a second meta tag. No fetches,
// scripts, forms, navigation of the parent, cookies or management origin access.
export function websitePreview(html: string): string {
return '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src \'none\'; base-uri \'none\'; form-action \'none\'"><meta name="referrer" content="no-referrer"></head><body>' + html + '</body></html>'
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowRef } from 'vue'
// In-memory handoff only. Receiving model content never writes files, opens a
// route or publishes. The trusted setup screen requires an explicit import.
export const pendingWebsiteHtml = shallowRef<string | null>(null)
export function prepareWebsiteImport(html: unknown): boolean {
if (typeof html !== 'string' || !html.trim() || new TextEncoder().encode(html).length > 512 * 1024 || html.includes('\0')) return false
pendingWebsiteHtml.value = html
return true
}
+1 -1
View File
@@ -21,7 +21,7 @@ export type AIContextCategory =
| 'bitcoin'
/** Actions AIUI can request Archy to perform */
export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
export type AIActionType = 'prepare-website' | 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
+1
View File
@@ -6,6 +6,7 @@ export interface GoalDefinition {
subtitle: string
icon: string
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' | 'community'
route?: string
requiredApps: string[]
steps: GoalStep[]
estimatedTime: string
+1
View File
@@ -325,6 +325,7 @@ function onAiuiMessage(event: MessageEvent) {
// the iframe survives deactivation that message will not be re-sent on
// re-entry, so it must NOT be reset on deactivate.
function armChatLive() {
if (!IS_DEMO && aiuiConnected.value) void connectionSetup.value?.syncSelection()
window.removeEventListener('message', onAiuiMessage)
window.addEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
+1 -1
View File
@@ -260,7 +260,7 @@
</button>
</div>
<div class="grid grid-cols-1 gap-3 mt-auto">
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="goal.route || `/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
<span>{{ goal.title }}</span>
</RouterLink>
</div>
@@ -11,7 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue'
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
const providerSync = vi.hoisted(() => vi.fn(async () => {}))
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: providerSync } } }))
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
@@ -66,6 +67,7 @@ function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
beforeEach(() => {
providerSync.mockClear()
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
})
@@ -206,12 +208,14 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
expect(providerSync).toHaveBeenCalledTimes(1)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(providerSync).toHaveBeenCalledTimes(2)
// No second 'ready' message is sent on reactivation — aiuiConnected must
// not have been reset to false by the deactivate/reactivate cycle.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
@@ -18,6 +18,23 @@ describe('NostrTabSigner visibility', () => {
window.dispatchEvent(event)
}
it('sends cloneable public identity fields and hides the frame after picker selection', async () => {
vi.useFakeTimers()
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(message => {
structuredClone(message) // Browser postMessage rejects Vue reactive proxies.
})
const wrapper = shallowMount(NostrTabSigner)
try {
parentMessage({ type: 'archipelago:signer-init', appId: 'blossom', appName: 'Blossom' })
const identity = reactive({ id: 'profile', name: 'Alice', did: 'did:example:alice', pubkey: 'public', nostr_pubkey: 'a'.repeat(64) })
wrapper.findComponent({ name: 'NostrIdentityPicker' }).vm.$emit('select', identity)
await Promise.resolve()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-identity', identity: expect.objectContaining({ id: 'profile' }) }), window.location.origin)
await vi.advanceTimersByTimeAsync(450)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:signer-hide' }, window.location.origin)
} finally { wrapper.unmount(); vi.useRealTimers() }
})
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
@@ -79,8 +79,9 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
* trusted. Once the signed catalog carries the app, portIsGateFronted is the
* normal source of truth.
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
const PRE_CATALOG_GATED_PORTS: Partial<Record<string, number>> = {
'archipelago-source': 8337,
'blossom': GENERATED_APP_PORTS.blossom,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
@@ -7,6 +7,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"blossom": 8191,
"botfights": 9100,
"btcpay-server": 23000,
"cuprate-ui": 18091,
@@ -56,6 +57,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI",
"blossom": "Blossom",
"botfights": "BotFights",
"btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)",
+2 -2
View File
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
version?: string
description?: string
category?: string
container?: { image?: string }
container?: { image?: string; build?: { tag?: string } }
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string; requires_host_frame?: boolean; open_in_new_tab?: boolean } }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
@@ -93,7 +93,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
description: app?.description || '',
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
author: app?.metadata?.author,
dockerImage: entry.image || app?.container?.image || '',
dockerImage: entry.image || app?.container?.image || app?.container?.build?.tag || '',
repoUrl: app?.metadata?.repo,
category: app?.category,
source: 'signed-catalog',
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { installPublishingApp } from '@/services/installPublishingApp'
import { useAppStore } from '@/stores/app'
import type { WebsiteProject } from '@/services/publishing'
interface GatewayStatus { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string }
const props = defineProps<{ gateway: GatewayStatus; project?: WebsiteProject | null; app?: { id: string; name: string } | null }>()
const emit = defineEmits<{ refresh: [] }>()
const appStore = useAppStore()
const appDomain = ref('')
const busy = ref(false)
const error = ref('')
const message = ref('')
const enrollment = ref<Record<string, unknown> | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const acknowledge = ref(false)
const testCertificates = ref(false)
const installed = computed(() => !!appStore.data?.['package-data']?.['public-web-router'])
const routeId = computed(() => props.project?.id ?? (props.app ? `app-${props.app.id}` : ''))
const connected = computed(() => props.gateway.routes.some(r => r.id === routeId.value))
async function perform(fn: () => Promise<void>) {
busy.value = true; error.value = ''; message.value = ''
try { await fn() } catch (e) { error.value = e instanceof Error ? e.message : 'Gateway action failed' }
finally { busy.value = false }
}
async function readEnrollment(event: Event) {
enrollment.value = null; acknowledge.value = false; error.value = ''
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
if (file.size > 65536) throw new Error('Enrollment file is too large')
const data = JSON.parse(await file.text())
if (!data || typeof data !== 'object' || typeof data.host !== 'string' || !Array.isArray(data.domains) || !data.domains.every((d: unknown) => typeof d === 'string')) throw new Error('Choose the enrollment file supplied by your gateway operator')
enrollment.value = data
} catch { error.value = 'Choose a valid gateway enrollment JSON file. Its contents stay in this setup session until you connect.' }
}
async function configure() {
if (!enrollment.value || !acknowledge.value) return
await perform(async () => {
await rpcClient.call({ method: 'publishing.gateway-configure', params: { enrollment: enrollment.value, certificate_mode: testCertificates.value ? 'test' : 'public', acknowledge: true }, maxRetries: 0 })
enrollment.value = null; acknowledge.value = false
if (fileInput.value) fileInput.value.value = ''
message.value = 'Gateway saved privately. Connect each published website when you are ready.'
emit('refresh')
})
}
async function route(enabled: boolean) {
if (!props.project && !props.app) return
await perform(async () => {
if (props.app) await rpcClient.call({ method: 'publishing.gateway-app-route', params: { app_id: props.app.id, domain: appDomain.value.trim(), enabled }, maxRetries: 0 })
else await rpcClient.call({ method: 'publishing.gateway-route', params: { id: props.project!.id, enabled }, maxRetries: 0 })
message.value = enabled ? 'Route requested. Check HTTPS and guest access before sharing the address.' : 'Gateway route removed. Other connections remain available.'
emit('refresh')
})
}
async function disconnect() {
await perform(async () => {
await rpcClient.call({ method: 'publishing.gateway-disconnect', maxRetries: 0 })
message.value = 'Gateway disconnected. Its local enrollment was removed; website drafts and certificates are retained.'
emit('refresh')
})
}
async function install() {
await perform(async () => { await installPublishingApp('public-web-router'); message.value = 'Router installation requested through the app catalogue.' })
}
</script>
<template>
<section class="space-y-4" aria-label="Your public gateway">
<h3 class="font-medium">Keep HTTPS on this node</h3>
<p class="text-sm text-white/60">Connect to a gateway you control using the open-source Public Web Router. The gateway forwards encrypted traffic; website certificates and keys stay here. You can reuse this connection for websites and supported apps.</p>
<p v-if="gateway.error" role="alert" class="text-sm text-amber-200">{{ gateway.error }}</p>
<button v-if="!installed" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="install">Install Public Web Router</button>
<template v-if="gateway.configured">
<p class="text-sm break-all">Gateway: {{ gateway.host }} · control port {{ gateway.port }}</p>
<p class="text-sm break-all">Assigned domains: {{ gateway.domains?.join(', ') }}</p>
<p v-if="gateway.certificate_mode === 'test'" class="text-sm text-amber-200">Test certificates only. Ordinary browsers will not trust this route.</p>
<p v-else class="text-sm text-white/60">Point your domain at the gateway’s public IP. It must forward public port 443 to this node so a certificate can be issued.</p>
<div v-if="project" class="flex flex-wrap gap-3">
<button v-if="!connected" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !installed || !project.fips_publication || !project.domain || !gateway.domains?.includes(project.domain.hostname)" @click="route(true)">Connect this published website</button>
<button v-else class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="route(false)">Disconnect this website from gateway</button>
</div>
<div v-if="app" class="space-y-3">
<p class="text-sm">Connect {{ app.name }} through its existing app gate. Guests still need app-only access; this does not grant dashboard access.</p>
<label v-if="!connected" class="block text-sm">Assigned domain for this app<input v-model="appDomain" maxlength="253" autocomplete="off" class="w-full mt-2 rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm" placeholder="app.yourdomain.com" :disabled="busy" /></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || (!connected && (!installed || !gateway.domains?.includes(appDomain.trim())))" @click="route(!connected)">{{ connected ? 'Disconnect this app from gateway' : 'Connect this app through its gate' }}</button>
</div>
<p v-if="project && !project.fips_publication" class="text-sm text-white/60">Publish the website upstream in Review and publish, then return here to connect it.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="disconnect">Disconnect all gateway routes</button>
</template>
<details class="space-y-4">
<summary class="cursor-pointer">{{ gateway.configured ? 'Replace gateway enrollment' : 'Connect your gateway' }}</summary>
<p class="text-sm text-white/60">Choose the private enrollment file supplied by your gateway operator. It contains connection credentials: keep it off Nostr and public file storage.</p>
<label class="block text-sm">Gateway enrollment file<input ref="fileInput" type="file" accept="application/json,.json" class="block w-full mt-2 text-sm" :disabled="busy" @change="readEnrollment" /></label>
<template v-if="enrollment">
<p class="text-sm break-all">Connect to {{ enrollment.host }} · assigned domains: {{ (enrollment.domains as string[]).join(', ') }}</p>
<label class="flex items-start gap-3 text-sm"><input v-model="acknowledge" type="checkbox" class="mt-1" :disabled="busy" /><span>I trust this gateway operator. Replacing enrollment disconnects existing gateway routes until I connect them again.</span></label>
<details><summary class="cursor-pointer text-sm">Testing options</summary><label class="flex items-start gap-3 mt-3 text-sm"><input v-model="testCertificates" type="checkbox" class="mt-1" :disabled="busy" /><span>Use private test certificates for isolated-port testing.</span></label></details>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !acknowledge" @click="configure">Save private gateway connection</button>
</template>
</details>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<p v-if="message" role="status" class="text-sm text-white/70">{{ message }}</p>
</section>
</template>
@@ -0,0 +1,621 @@
<script setup lang="ts">
import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { installPublishingApp } from '@/services/installPublishingApp'
import { pendingWebsiteHtml } from '@/services/websiteImport'
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
import PublicWebGateway from './PublicWebGateway.vue'
import { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing'
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
import BackButton from '@/components/BackButton.vue'
import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue'
import SearchableAppSelect from '@/components/SearchableAppSelect.vue'
import AIConnectionModal from '@/components/AIConnectionModal.vue'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const blossomInstalled = computed(() => !!appStore.data?.['package-data']?.blossom)
const websiteMode = computed(() => route.name === 'publish-website')
const status = ref<PublishingStatus | null>(null)
const error = ref('')
const message = ref('')
const busy = ref(false)
const projectId = ref('')
const name = ref('My website')
const selected = ref<PublishRoute[]>([])
const html = ref('')
const hostname = ref('')
const destination = ref('')
const showAiConnection = ref(false)
const aiConnection = ref<InstanceType<typeof AIConnectionModal>>()
function openAiCredit() { showAiConnection.value = true; aiConnection.value?.showRoutstr() }
const dns = ref<DnsPlan | null>(null)
const httpsCheck = ref<HttpsCheck | null>(null)
watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = null })
const acknowledgeFips = ref(false)
const acknowledgeTor = ref(false)
const identities = ref<NsiteIdentity[]>([])
const identityId = ref('')
const blossom = ref('')
const localNsite = ref(true)
const nsiteServer = computed(() => localNsite.value ? (current.value?.domain?.hostname ? `https://${current.value.domain.hostname}` : '') : blossom.value)
const relays = ref('')
const gateway = ref('')
const nsiteUrl = ref('')
const guestApp = ref('')
const guestLabel = ref('Guest')
const guestHours = ref(24)
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
onDeactivated(() => { issuedAccess.value = null })
onBeforeUnmount(() => { issuedAccess.value = null })
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
const acknowledgeNostr = ref(false)
const acknowledgeUpload = ref(false)
const nsiteReview = ref<{ projectId: string; version: number; identity: NsiteIdentity; relays: string[]; prepared: PreparedNsite } | null>(null)
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
const current = computed(() => status.value?.state.projects[projectId.value])
const projects = computed(() => Object.values(status.value?.state.projects ?? {}))
const publicName = computed(() => selected.value.includes('public-web') || selected.value.includes('nostr'))
const preview = computed(() => websitePreview(html.value))
const walkthroughStep = ref('connections')
const walkthroughStarted = ref(false)
watch(websiteMode, async () => {
issuedAccess.value = null
walkthroughStarted.value = false
walkthroughStep.value = 'connections'
await refresh()
})
watch(pendingWebsiteHtml, value => {
if (value !== null && websiteMode.value) walkthroughStep.value = 'design'
})
const walkthroughSteps = computed(() => websiteMode.value ? [
{ id: 'connections', title: 'Choose your connections', description: status.value?.state.connections.length ? 'Your saved connection choices are ready to reuse. You can change them for this website.' : 'Choose where visitors will find your website. You can use more than one connection.', complete: !!status.value?.state.connections.length },
...(!blossomInstalled.value ? [{ id: 'storage', title: 'Install local website storage', description: 'Optional: install Blossom to keep signed website files on this node.', complete: false }] : []),
{ id: 'design', title: 'Create your website', description: 'Describe a page, import it from AIUI, or edit your HTML. Preview and save it privately before publishing.', complete: !!current.value?.draft },
...(publicName.value ? [{ id: 'domain', title: 'Choose your address', description: 'Use your own domain, buy one privately, or use a compatible nsite gateway.', complete: !!current.value?.domain }] : []),
{ id: 'publish', title: 'Review and publish', description: 'Choose exactly what to share, publish each connection separately, and check that visitors can reach it.', complete: false },
] : [
{ id: 'connections', title: 'Choose your connections', description: 'Choose FIPS, public HTTPS or Tor. Save your preferences to reuse them when publishing a website.', complete: !!status.value?.state.connections.length },
{ id: 'sharing', title: 'Choose an app and grant access', description: 'Give a guest access to one supported app with an expiry date. Your dashboard stays private.', complete: false },
])
const routeHints: Record<PublishRoute, string> = {
fips: 'For people connected to FIPS · No domain needed',
'public-web': 'For ordinary browsers · Domain and gateway needed',
tor: 'For Tor Browser · No domain needed',
nostr: 'For nsite gateways · Public copies may remain',
}
const continueLabel = computed(() => walkthroughStep.value === 'storage' ? 'Continue without installing' : 'Save and continue')
const continueDisabled = computed(() => walkthroughStep.value === 'connections' ? !selected.value.length : ['design', 'domain'].includes(walkthroughStep.value) && (!current.value || !html.value.trim()))
async function continueSetup(next: string) {
if (walkthroughStep.value === 'connections' && websiteMode.value && !current.value) await saveSharedConnections()
else if (['connections', 'design', 'domain'].includes(walkthroughStep.value)) await save()
if (!error.value) walkthroughStep.value = next
}
const publishedRoutes = computed(() => [current.value?.fips_publication ? 'FIPS' : '', current.value?.tor_publication ? 'Tor' : '', current.value?.nsite_receipt?.accepted_relays.length ? 'Nostr' : ''].filter(Boolean))
const routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr'))
async function perform(work: () => Promise<void>) {
if (busy.value) return
busy.value = true; error.value = ''; message.value = ''
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : 'The operation failed. Your saved project has been retained.' }
finally { busy.value = false }
}
function selectProject(p: WebsiteProject) {
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
identityId.value = p.nsite_receipt?.identity_id ?? ''; blossom.value = p.nsite_receipt?.server ?? ''; acknowledgeNostr.value = false; nsiteUrl.value = ''
}
async function refresh() {
await perform(async () => {
status.value = await publishing.status()
if (!relays.value) relays.value = (status.value.nostr_relays ?? []).join('\n')
if (!websiteMode.value) selected.value = [...status.value.state.connections]
else if (current.value) selectProject(current.value)
else if (projects.value[0]) selectProject(projects.value[0])
if (!walkthroughStarted.value) {
if (websiteMode.value && status.value.state.connections.length) {
if (!current.value) selected.value = [...status.value.state.connections]
walkthroughStep.value = blossomInstalled.value ? 'design' : 'storage'
}
walkthroughStarted.value = true
if (websiteMode.value && pendingWebsiteHtml.value !== null) walkthroughStep.value = 'design'
}
})
}
async function create() {
await perform(async () => {
if (!status.value) return
const choices = [...selected.value]
const result = await publishing.update(status.value.state.version, { action: 'create', name: name.value })
status.value.state = result.state
if (result.project_id) { selectProject(result.state.projects[result.project_id]!); selected.value = choices }
message.value = 'Website project created on your node.'
})
}
async function importFromAiui() {
await perform(async () => {
if (!status.value || pendingWebsiteHtml.value === null) return
const incoming = pendingWebsiteHtml.value
const result = await publishing.update(status.value.state.version, { action: 'create', name: 'Website from AIUI' })
status.value.state = result.state
if (result.project_id) {
selectProject(result.state.projects[result.project_id]!)
html.value = incoming
pendingWebsiteHtml.value = null
message.value = 'AIUI draft imported into a new project. Preview it, then save before publishing.'
}
})
}
async function saveSharedConnections() {
await perform(async () => {
if (!status.value) return
const result = await publishing.update(status.value.state.version, { action: 'connections', routes: selected.value })
status.value.state = result.state
message.value = 'Connection preferences saved. Continue to create your website; nothing has been published.'
})
}
async function save() {
await perform(async () => {
if (!status.value) return
const change = websiteMode.value ? {
action: 'save', id: projectId.value, name: name.value, routes: selected.value,
domain: publicName.value && hostname.value.trim() ? { hostname: hostname.value, destination: destination.value.trim() || null } : null,
html: html.value,
} : { action: 'connections', routes: selected.value }
const result = await publishing.update(status.value.state.version, change)
status.value.state = result.state
if (websiteMode.value) hostname.value = current.value?.domain?.hostname ?? ''
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
})
}
async function restore(revision: string) {
await perform(async () => {
if (!status.value) return
const result = await publishing.update(status.value.state.version, { action: 'restore', id: projectId.value, revision })
status.value.state = result.state
selectProject(result.state.projects[projectId.value]!)
message.value = 'Previous draft restored. Published content has not changed.'
})
}
async function setFipsPublication(enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'publish-fips', id: projectId.value, acknowledge_public: acknowledgeFips.value }
: { action: 'unpublish-fips', id: projectId.value })
status.value.state = result.state
status.value = await publishing.status()
acknowledgeFips.value = false
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
})
}
async function setArchiveSharing(route: 'fips' | 'tor', enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'share-archive', id: projectId.value, route, acknowledge_public: true }
: { action: 'unshare-archive', id: projectId.value, route })
status.value.state = result.state
message.value = enable ? 'This archived snapshot is available on the selected website connection. Other Blossom files remain private.' : 'File sharing stopped on this connection. Copies already downloaded may remain.'
})
}
async function setTorPublication(enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'publish-tor', id: projectId.value, acknowledge_public: acknowledgeTor.value }
: { action: 'unpublish-tor', id: projectId.value })
status.value.state = result.state
status.value = await publishing.status()
acknowledgeTor.value = false
message.value = enable ? 'Onion publication requested. Tor may take a few minutes to connect; reload to check its address.' : 'Onion website unpublished. Its address keys are retained so you can publish again at the same address.'
})
}
async function prepareDns() {
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
}
async function verifyHttps() {
await perform(async () => {
httpsCheck.value = null
if (!status.value) return
httpsCheck.value = await publishing.verifyHttps(projectId.value, status.value.state.version)
})
}
async function installBlossom() {
await perform(async () => {
await installPublishingApp('blossom')
message.value = 'Blossom installation requested through the app catalogue. This step will be skipped when installation is recorded.'
})
}
async function loadIdentities() { await perform(async () => { identities.value = await nsiteIdentities() }) }
async function createGuestAccess() {
await perform(async () => {
issuedAccess.value = null
issuedAccess.value = await rpcClient.call({ method: 'publishing.access-create', params: { app_id: guestApp.value, label: guestLabel.value, hours: guestHours.value }, maxRetries: 0 })
status.value = await publishing.status()
message.value = 'App-only access created. Copy the token now; it cannot be shown again.'
})
}
async function revokeGuestAccess(id: string) {
await perform(async () => {
await rpcClient.call({ method: 'publishing.access-revoke', params: { id }, maxRetries: 0 })
if (issuedAccess.value?.id === id) issuedAccess.value = null
status.value = await publishing.status()
message.value = 'Access revoked for new requests. Content already downloaded cannot be recalled.'
})
}
async function storeLocally() {
await perform(async () => {
const identity = identities.value.find(i => i.id === identityId.value)
if (!identity || !status.value || !current.value) throw new Error('Choose a profile identity and save a draft first')
await storeLocalWebsite(projectId.value, status.value.state.version, identity)
status.value = await publishing.status()
message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.'
})
}
watch([projectId, blossom, localNsite, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true })
async function reviewNsite() {
await perform(async () => {
if (!status.value || !current.value) return
const identity = identities.value.find(i => i.id === identityId.value)
if (!identity) throw new Error('Choose a profile identity first')
const targets = relayAddresses(relays.value)
const prepared = await prepareNsite(projectId.value, status.value.state.version, nsiteServer.value, current.value.draft, localNsite.value)
nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared }
acknowledgeNostr.value = false; acknowledgeUpload.value = false
})
}
async function handleNsite(action: 'publish' | 'retry' | 'delete') {
await perform(async () => {
if (!status.value || !current.value || !acknowledgeNostr.value) return
const targets = relayAddresses(relays.value)
const identity = identities.value.find(i => i.id === identityId.value)
const receipt = current.value.nsite_receipt
try {
if (action === 'retry' && receipt) await retryNsite(projectId.value, receipt, targets)
else {
if (!identity) throw new Error('Load identities and choose the profile identity you want to use')
if (action === 'delete' && receipt) await requestNsiteDeletion(projectId.value, receipt, identity, targets)
else {
const review = nsiteReview.value
if (!review || !acknowledgeUpload.value) throw new Error('Review the exact upload and explicitly approve replication first')
await publishNsite(review.projectId, review.version, review.identity, review.relays, review.prepared)
}
}
} finally {
acknowledgeNostr.value = false; acknowledgeUpload.value = false; nsiteReview.value = null
// A failed relay delivery can still leave a durable upload/manifest. Show
// that receipt so retry never silently uploads or signs a second copy.
status.value = await publishing.status()
}
message.value = action === 'delete' ? 'A relay accepted the deletion request. Other relays, Blossom servers and cached copies may retain the website.' : 'The uploaded bytes were checked and a relay accepted the named-site manifest. Gateway availability still needs checking.'
})
}
async function unshareNsiteAsset() {
await perform(async () => {
if (!status.value || !current.value) return
await publishing.update(status.value.state.version, { action: 'unshare-nsite-asset', id: projectId.value })
status.value = await publishing.status()
message.value = 'Local nsite file sharing stopped. Published manifests and downloaded copies may remain.'
})
}
async function showNsiteAddress() {
await perform(async () => {
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
})
}
function download() {
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
onMounted(refresh)
</script>
<template>
<main class="publishing-guide w-full min-w-0 pb-6">
<BackButton label="Back to Setup" desktop-margin="mb-6" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" />
<div class="flex flex-wrap items-start justify-between gap-4 mb-8">
<div><h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1>
<p class="text-white/70">{{ websiteMode ? 'Create a website on your node and choose where people can find it.' : 'Choose how people will connect to selected services on your node.' }}</p></div>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="refresh">Reload</button>
</div>
<p v-if="error" role="alert" class="rounded-xl p-4 mb-4 bg-red-500/10 text-red-200">{{ error }}</p>
<p v-if="message" role="status" class="rounded-xl p-4 mb-4 bg-green-500/10 text-green-200">{{ message }}</p>
<p v-if="busy" role="status" class="text-white/60 mb-4">Working…</p>
<div v-if="websiteMode" class="flex flex-wrap items-center gap-2 text-xs text-white/65 mb-6">
<span class="rounded-full border border-white/15 bg-white/5 px-3 py-1.5">{{ publishedRoutes.length ? `Published on ${publishedRoutes.join(', ')}` : 'Private until you publish' }}</span>
<span v-if="blossomInstalled" class="rounded-full border border-green-400/20 bg-green-400/5 px-3 py-1.5 text-green-200">Blossom installed</span>
<span v-if="status?.state.connections.length" class="rounded-full border border-white/15 bg-white/5 px-3 py-1.5">Saved connections ready to reuse</span>
</div>
<SetupWalkthrough v-if="status" v-model="walkthroughStep" :steps="walkthroughSteps" :busy="busy" :continue-label="continueLabel" :continue-disabled="continueDisabled" @next="continueSetup">
<template #connections>
<section class="space-y-4">
<p class="text-sm text-white/65">Select all that apply. You can change these choices later.</p>
<div class="grid gap-3 sm:grid-cols-2">
<label v-for="option in routes" :key="option.id" class="connection-option flex items-start gap-3 rounded-xl border p-4 cursor-pointer transition-colors" :class="selected.includes(option.id) ? 'border-orange-300/50 bg-orange-400/10' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.07]'">
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
<span class="min-w-0"><span class="font-semibold text-white/95">{{ option.title }}</span><span class="block text-xs text-orange-100/75 mt-1">{{ routeHints[option.id] }}</span><span class="block text-sm text-white/60 mt-1">{{ option.description }}</span>
<span v-if="websiteMode && status.state.connections.includes(option.id)" class="block text-xs text-amber-200 mt-2">Already selected in connection setup; reachability still needs verification.</span>
</span>
</label>
</div>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'Your node has a FIPS address. Check visitor access after publishing or sharing an app.' : 'Your node does not have a FIPS address yet. Connect FIPS in Network settings before publishing here.' }}</p>
<PublicWebGateway v-if="!websiteMode && selected.includes('public-web') && status.gateway" :gateway="status.gateway" @refresh="refresh" />
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
</section>
</template>
<template #storage>
<section v-if="websiteMode" class="space-y-3" data-testid="blossom-setup">
<h2 class="text-lg font-semibold">Local website files</h2>
<template v-if="blossomInstalled">
<p>Blossom is installed. You can skip installation.</p>
<RouterLink to="/dashboard/apps/blossom" class="underline">Manage local Blossom</RouterLink>
<template v-if="current">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="loadIdentities">Choose a storage identity</button>
<label class="block">Profile for local files<select v-model="identityId" :disabled="busy" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !identityId || !current.draft || html !== current.draft" @click="storeLocally">Store saved website in local Blossom</button>
<p v-if="html !== current.draft" class="text-sm">Save your edits before storing this version in Blossom.</p>
<p v-if="current.local_archive" class="text-sm break-all">Verified local snapshot: {{ current.local_archive.size }} bytes · {{ new Date(current.local_archive.created_at).toLocaleString() }} · SHA-256 {{ current.local_archive.sha256 }}</p>
<p class="text-sm text-white/60">This stores the saved draft shown below. Later edits need another explicit store. Local files require node login; this does not create a public Blossom endpoint.</p>
</template>
</template>
<template v-else>
<p>Install Blossom from the app catalogue to store website files on this node. Create a profile identity first; Blossom uses the normal Archipelago signer.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="installBlossom">{{ busy ? 'Installing…' : 'Install Blossom' }}</button>
<RouterLink to="/dashboard/marketplace/blossom" class="text-sm underline ml-3">View app details</RouterLink>
<p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p>
</template>
<p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p>
</section>
</template>
<template #design>
<section class="space-y-4">
<h2 class="text-lg font-semibold">Your AI connection</h2>
<p class="text-sm text-white/60">Use Routstr by default, or choose Claude or OpenAI with your API key. Your selected provider creates the draft; you review it here before publishing.</p>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="showAiConnection = true">Choose AI provider</button>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="openAiCredit">Routstr credit and top up</button>
</div>
<p class="text-xs text-white/50">Routstr uses your node’s ecash balance within the spending allowance you set. Topping up and changing the allowance are separate choices.</p>
</section>
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="space-y-3">
<h2 class="text-lg font-semibold">Continue from AIUI</h2>
<p class="text-sm text-white/60">Create a new project from the HTML you selected in AIUI. Existing projects remain unchanged.</p>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="importFromAiui">Import into a new website</button>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="pendingWebsiteHtml = null">Discard import</button>
</div>
</section>
<section v-if="websiteMode" class="space-y-4">
<h2 class="text-lg font-semibold">{{ projects.length ? 'Your websites' : 'Start with an idea' }}</h2>
<div v-if="!current" class="space-y-3">
<p class="text-sm text-white/65">Ask AIUI to make a simple HTML website. When its preview is ready, choose “Continue to website setup” to bring it here.</p>
<RouterLink to="/dashboard/chat" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Create with AIUI <span aria-hidden="true">↗</span></RouterLink>
<p class="text-sm text-white/50">Or give your website a name and start with a blank page below.</p>
</div>
<div v-if="projects.length" class="flex flex-wrap gap-2">
<button v-for="p in projects" :key="p.id" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button>
</div>
<label class="block">Website name<input v-model="name" maxlength="100" class="field mt-2" /></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="create">Create another website</button>
</section>
<section v-if="websiteMode && current" class="space-y-4">
<h2 class="text-lg font-semibold">Describe and preview</h2>
<RouterLink to="/dashboard/chat" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Create with AIUI <span aria-hidden="true">↗</span></RouterLink>
<p class="text-sm text-white/60">In AIUI, use “Continue to website setup” on your HTML preview to bring it back here for review.</p>
<details class="guide-details"><summary>Edit or paste HTML</summary><div class="pt-4">
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
</div></details>
<div class="flex items-center justify-between gap-2"><h3 class="font-medium">Your preview</h3><span class="text-xs text-white/50">Private preview</span></div>
<iframe :srcdoc="preview" sandbox="" referrerpolicy="no-referrer" title="Isolated website preview" class="w-full h-96 rounded-xl bg-white" />
<p class="text-xs text-white/50">Your preview stays private. This first version supports static pages; scripts and external resources are blocked.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!html" @click="download">Download HTML</button>
</section>
<details v-if="websiteMode && blossomInstalled" class="guide-details" data-testid="blossom-setup"><summary>Keep a signed copy in local Blossom</summary><div class="space-y-3 pt-4">
<h2 class="text-lg font-semibold">Local website files</h2>
<template v-if="blossomInstalled">
<p>Blossom is installed. You can skip installation.</p>
<RouterLink to="/dashboard/apps/blossom" class="underline">Manage local Blossom</RouterLink>
<template v-if="current">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="loadIdentities">Choose a storage identity</button>
<label class="block">Profile for local files<select v-model="identityId" :disabled="busy" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !identityId || !current.draft || html !== current.draft" @click="storeLocally">Store saved website in local Blossom</button>
<p v-if="html !== current.draft" class="text-sm">Save your edits before storing this version in Blossom.</p>
<p v-if="current.local_archive" class="text-sm break-all">Verified local snapshot: {{ current.local_archive.size }} bytes · {{ new Date(current.local_archive.created_at).toLocaleString() }} · SHA-256 {{ current.local_archive.sha256 }}</p>
<p class="text-sm text-white/60">This stores the saved draft shown below. Later edits need another explicit store. Local files require node login; this does not create a public Blossom endpoint.</p>
</template>
</template>
<template v-else>
<p>Install Blossom from the app catalogue to store website files on this node. Create a profile identity first; Blossom uses the normal Archipelago signer.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="installBlossom">{{ busy ? 'Installing…' : 'Install Blossom' }}</button>
<RouterLink to="/dashboard/marketplace/blossom" class="text-sm underline ml-3">View app details</RouterLink>
<p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p>
</template>
<p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p>
</div></details>
<section v-if="websiteMode && current?.revisions.length" class="space-y-3">
<h2 class="text-lg font-semibold">Saved revisions</h2>
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex flex-wrap items-center gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="restore(revision.id)">Restore draft</button></div>
</section>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || (websiteMode && !current && walkthroughStep !== 'connections')" @click="walkthroughStep === 'connections' && websiteMode && !current ? saveSharedConnections() : save()">{{ walkthroughStep === 'connections' ? 'Save connection choices' : 'Save website draft and choices' }}</button>
</template>
<template #domain>
<section v-if="websiteMode && publicName" class="space-y-4">
<h2 class="text-lg font-semibold">Your domain</h2>
<p class="text-sm text-white/60">Use a domain you own, or buy one with Bitcoin or Lightning. FIPS and Tor addresses do not need a domain purchase.</p>
<a href="https://mynymbox.io/domainregistration" target="_blank" rel="noopener noreferrer" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Buy a domain through Mynymbox ↗</a>
<p class="text-xs text-white/50">Mynymbox is the registrant of record; you retain contractual control and transfer rights. Complete checkout yourself, then return here. No hosting purchase is needed.</p>
<label class="block">Website hostname<input v-model="hostname" class="field mt-2" placeholder="www.yourdomain.com" /></label>
<label class="block">Gateway hostname or public IP<input v-model="destination" class="field mt-2" placeholder="Use the destination supplied by your gateway" /></label>
<p class="text-sm text-white/60">For a tunnel, point DNS at the public gateway. For a direct connection, use the node’s public IP. Do not use a home-network, FIPS or onion address for public web DNS.</p>
<PublicWebGateway v-if="selected.includes('public-web') && status.gateway" :gateway="status.gateway" :project="current" @refresh="refresh" />
<div v-if="selected.includes('public-web') && current?.fips_publication && status.fips_address" class="space-y-2 rounded-lg border border-white/10 p-4">
<h3 class="font-medium">Use an existing reverse proxy</h3>
<p class="text-sm text-white/60">If your proxy can reach this node over FIPS, you can reuse that connection. In Nginx Proxy Manager, add a separate Proxy Host with these settings:</p>
<dl class="text-sm grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<dt>Domain</dt><dd class="font-mono break-all">{{ hostname || 'Your website hostname' }}</dd>
<dt>Scheme</dt><dd>http</dd>
<dt>Forward host</dt><dd class="font-mono break-all">[{{ status.fips_address }}]</dd>
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
</dl>
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. Removing the upstream publication also disconnects this proxy route.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="hostname !== current.domain?.hostname || !current.routes.includes('public-web')" @click="verifyHttps">Check public HTTPS</button>
<p v-if="httpsCheck" class="text-sm text-green-200">Verified https://{{ httpsCheck.hostname }}/ at {{ new Date(httpsCheck.checked_at).toLocaleString() }}: valid TLS and exact published content. Checked from this node; also test from an outside device.</p>
<p v-else class="text-sm text-amber-200">Public HTTPS has not been verified for these saved settings.</p>
</div>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
<div v-if="dns" class="space-y-3">
<p>In Mynymbox, open Domains → DNS Management → your domain → Manage records → Add Record.</p>
<div class="overflow-x-auto"><table class="w-full text-sm text-left"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr></thead><tbody><tr v-for="record in dns.records" :key="record.name"><td>{{ record.record_type }}</td><td class="select-all">{{ record.name }}</td><td class="select-all">{{ record.value }}</td><td>{{ record.ttl }}</td></tr></tbody></table></div>
<p v-for="note in dns.notes" :key="note" class="text-sm text-white/60">{{ note }}</p>
<p class="text-amber-200 text-sm">Instructions prepared — DNS and HTTPS have not been verified.</p>
<a href="https://mynymbox.io/docs?doc=domains/dns-records" target="_blank" rel="noopener noreferrer" class="underline text-sm">Mynymbox’s DNS guide ↗</a>
</div>
</section>
</template>
<template #sharing>
<section v-if="!websiteMode" class="space-y-3">
<h2 class="text-lg font-semibold">Grant access to an app</h2>
<p v-if="!shareableApps.length" class="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/70">No installed apps currently support guest sharing. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
<p v-if="shareableApps.length" class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
<template v-if="shareableApps.length">
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
<template v-if="guestTarget">
<PublicWebGateway v-if="selected.includes('public-web') && status.gateway?.configured" :gateway="status.gateway" :app="guestTarget" @refresh="refresh" />
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
<div v-if="guestTarget" class="space-y-2 text-sm">
<p v-if="status.fips_address" class="break-all">FIPS address: <a :href="`https://[${status.fips_address}]:${guestTarget.port}/`" target="_blank" rel="noopener noreferrer" class="underline">https://[{{ status.fips_address }}]:{{ guestTarget.port }}/</a></p>
<p v-if="selected.includes('public-web')">For your public reverse proxy, use the FIPS address above as its forward host and port {{ guestTarget.port }}, with upstream scheme HTTP. Keep the app gate enabled. Configure the application's public URL if it requires one.</p>
<p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p>
</div>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button>
</template>
</template>
<div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2">
<p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p>
<code class="block break-all select-all">{{ issuedAccess.token }}</code>
<p class="text-sm">The guest opens the app address and chooses “Have an app-only access token?”. API clients can use it as an Authorization Bearer token. It cannot log in to the dashboard.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="issuedAccess = null">Hide token</button>
</div>
<div v-for="grant in status.grants ?? []" :key="grant.id" class="flex flex-wrap items-center gap-3 border-t border-white/10 pt-3">
<p>{{ grant.label }} · {{ grant.apps.join(', ') }} · {{ grant.expires_at ? new Date(grant.expires_at * 1000).toLocaleString() : 'No expiry' }}</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="revokeGuestAccess(grant.id)">Revoke access</button>
</div>
</section>
</template>
<template #publish>
<section v-if="current?.draft" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">Ready to share?</h2><span class="text-xs text-white/55">{{ current.name }} · Saved version</span></div>
<p class="text-sm text-white/65">Check the page below, then publish using your chosen connections. Nothing is sent just by opening this step.</p>
<p v-if="html !== current.draft" class="text-sm text-amber-200">You have unsaved edits. This preview shows the saved version that will be published.</p>
<iframe :srcdoc="websitePreview(current.draft)" sandbox="" referrerpolicy="no-referrer" title="Saved website publication preview" class="w-full h-64 rounded-xl bg-white" />
</section>
<p v-else class="text-sm text-white/65">Create and save your website first, then return here to publish it.</p>
<section v-if="websiteMode && current && status.publication_enabled && (selected.includes('fips') || selected.includes('public-web') || current.fips_publication)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish from your node</h2>
<p class="text-sm text-white/60">Visitors on FIPS can read this page. If you chose public web, your domain’s proxy uses this same publication.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button v-if="current.fips_publication" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="setFipsPublication(false)">Unpublish from FIPS</button>
</div>
<div v-if="current.fips_publication" class="text-sm space-y-2">
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
<p v-if="listener?.error" role="alert">{{ listener.error }}</p>
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
<p class="text-white/60">Open this address from another FIPS device to check visitor access.</p>
<button v-if="selected.includes('public-web')" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.fips_publication" :archive="current.local_archive" :address="selected.includes('public-web') && current.domain?.hostname ? `https://${current.domain.hostname}/` : listener?.address" :busy="busy" @change="setArchiveSharing('fips', $event)" />
<details class="text-xs text-white/50"><summary>Connection details</summary><p class="mt-2">Website port {{ current.fips_publication.port }}. A local listener does not confirm access from another device.</p></details>
</div>
</section>
<section v-if="websiteMode && current && status.publication_enabled && (selected.includes('tor') || current.tor_publication)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on Tor</h2>
<p class="text-sm text-white/60">Share an onion address without buying a domain. Anyone who knows the address can read the page in Tor Browser. Your node keeps the address keys when you unpublish.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeTor" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors using Tor.</span></label>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeTor || !current.routes.includes('tor') || !current.draft" @click="setTorPublication(true)">{{ current.tor_publication ? 'Publish saved update on Tor' : 'Publish saved website on Tor' }}</button>
<button v-if="current.tor_publication" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="setTorPublication(false)">Unpublish from Tor</button>
</div>
<div v-if="current.tor_publication" class="text-sm space-y-2">
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.tor_publication" :archive="current.local_archive" :address="onion?.onion_address ? `http://${onion.onion_address}/` : null" :busy="busy" @change="setArchiveSharing('tor', $event)" />
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
</div>
</section>
<section v-if="websiteMode && current && (selected.includes('nostr') || current.nsite_receipt)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
<p class="text-sm text-white/60">Serve a reviewed static copy from your node or another Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="loadIdentities">Load signing identities</button>
<label class="block">Profile identity<select v-model="identityId" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
<p class="text-xs text-white/50">The node's operational identity is excluded. Your private key stays in the existing signer.</p>
<label class="flex items-start gap-3"><input v-model="localNsite" type="checkbox" class="mt-1" /><span>Serve the reviewed file from this node’s Blossom storage</span></label>
<p v-if="localNsite" class="text-sm text-white/60">Publish and verify this website’s public HTTPS connection first. Only the reviewed file is shared; private Blossom files stay protected. Your node must stay online for nsite gateways to fetch it.</p>
<p v-if="localNsite" class="text-sm break-all">File address: {{ nsiteServer || 'Set this website’s domain first' }}</p>
<label v-else class="block">External Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
<label class="block">Relays<textarea v-model="relays" rows="3" class="field mt-2" placeholder="wss://your-relay.example" /></label>
<p class="text-sm text-white/60">Choose servers you trust or host your own. The Blossom server must allow browser uploads and reads. Paid storage requires a separate arrangement; this flow never pays automatically.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!identityId || !nsiteServer || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
<div v-if="nsiteReview" class="rounded-xl border border-amber-300/30 p-4 space-y-3">
<h3 class="font-semibold">Review exactly what will leave your node</h3>
<p class="text-sm">Signing identity: {{ nsiteReview.identity.name }} <span class="font-mono break-all">{{ nsiteReview.identity.nostr_pubkey }}</span></p>
<p class="text-sm break-all">{{ nsiteReview.prepared.local ? 'Public file origin' : 'Upload destination' }}: {{ nsiteReview.prepared.server }}</p>
<p class="text-sm break-all">Announcement relays: {{ nsiteReview.relays.join(', ') }}</p>
<p class="text-xs font-mono break-all">Content SHA-256: {{ nsiteReview.prepared.sha256 }}</p>
<iframe :srcdoc="websitePreview(nsiteReview.prepared.html)" sandbox="" referrerpolicy="no-referrer" title="Exact nsite publication preview" class="w-full h-64 rounded-xl bg-white" />
<details><summary>Inspect the exact uploaded HTML</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ nsiteReview.prepared.html }}</pre></details>
<details><summary>Inspect the public manifest</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(nsiteReview.prepared.manifest, null, 2) }}</pre></details>
<p class="text-sm text-amber-200">Check for personal information, credentials, private addresses and anything you do not want copied. Sanitising HTML does not remove sensitive text. Public copies cannot be guaranteed erased.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeUpload" type="checkbox" class="mt-1" /><span>{{ localNsite ? 'I approve making these exact website bytes publicly readable from my node.' : 'I approve sending these exact website bytes to this Blossom server.' }}</span></label>
</div>
<label class="flex items-start gap-3"><input v-model="acknowledgeNostr" type="checkbox" class="mt-1" /><span>I approve sending the displayed manifest or removal request to the listed relays when I press its action button. It identifies the author, and copies may remain after a deletion request.</span></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !nsiteServer" @click="handleNsite('publish')">{{ localNsite ? 'Share file and publish manifest' : 'Upload and publish saved website' }}</button>
<div v-if="current.fips_publication?.nsite_asset" class="space-y-2">
<p class="text-sm break-all">Local nsite file is publicly readable: {{ current.fips_publication.nsite_asset.receipt.sha256 }}</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="unshareNsiteAsset">Stop sharing the local nsite file</button>
</div>
<template v-if="current.nsite_receipt">
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
<p class="text-sm break-all">Retry destinations: {{ relays }}. Removal also contacts relays that previously accepted this manifest: {{ current.nsite_receipt.accepted_relays.join(', ') || 'none recorded' }}.</p>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeNostr || current.nsite_receipt.deletion_requested" @click="handleNsite('retry')">Retry manifest delivery</button>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeNostr || !identityId" @click="handleNsite('delete')">Request removal from relays</button>
</div>
<label class="block">Compatible nsite gateway<input v-model="gateway" class="field mt-2" placeholder="https://your-nsite-gateway.example" /></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!gateway" @click="showNsiteAddress">Show browser address</button>
<a v-if="nsiteUrl" :href="nsiteUrl" target="_blank" rel="noopener noreferrer" class="block break-all underline">{{ nsiteUrl }}</a>
</template>
</section>
</template>
</SetupWalkthrough>
<AIConnectionModal ref="aiConnection" :show="showAiConnection" @close="showAiConnection = false" />
</main>
</template>
<style scoped>
.field { display: block; width: 100%; border: 1px solid rgb(255 255 255 / .15); border-radius: .5rem; padding: .75rem; background: rgb(0 0 0 / .2); color: white; }
button:disabled { opacity: .5; cursor: not-allowed; }
th, td { padding: .5rem; }
.guide-details { border: 1px solid rgb(255 255 255 / .1); border-radius: .75rem; padding: 1rem; background: rgb(255 255 255 / .025); }
.guide-details > summary { cursor: pointer; color: rgb(255 255 255 / .8); font-size: .875rem; font-weight: 500; }
.connection-option input { accent-color: #fb923c; width: 1.125rem; height: 1.125rem; flex-shrink: 0; }
.field:focus-visible, summary:focus-visible { outline: 2px solid #fb923c; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .transition-colors { transition: none; } }
</style>
@@ -0,0 +1,53 @@
vi.mock('@/services/installPublishingApp', () => ({ installPublishingApp: vi.fn() }))
import { installPublishingApp } from '@/services/installPublishingApp'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const app = vi.hoisted(() => ({ installPackage: vi.fn(), data: { 'package-data': {} as Record<string, unknown> } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => app }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import PublicWebGateway from '../PublicWebGateway.vue'
const gateway = { configured: false, routes: [] }
beforeEach(() => { vi.clearAllMocks(); app.data['package-data'] = {}; vi.mocked(rpcClient.call).mockResolvedValue({}) })
describe('private gateway walkthrough', () => {
it('imports credentials privately and requires deliberate enrollment consent', async () => {
const wrapper = mount(PublicWebGateway, { props: { gateway } })
const enrollment = { host: 'gateway.example', domains: ['site.example'], enrollment_token: 'synthetic-private-value' }
const input = wrapper.get('input[type=file]')
Object.defineProperty(input.element, 'files', { value: [{ size: 100, text: async () => JSON.stringify(enrollment) }] })
await input.trigger('change'); await flushPromises()
expect(wrapper.text()).toContain('gateway.example')
expect(wrapper.text()).not.toContain('synthetic-private-value')
const save = wrapper.findAll('button').find(b => b.text() === 'Save private gateway connection')!
expect(save.attributes('disabled')).toBeDefined()
expect(rpcClient.call).not.toHaveBeenCalled()
await wrapper.findAll('input[type=checkbox]')[0]!.setValue(true)
await save.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'publishing.gateway-configure', params: { enrollment, certificate_mode: 'public', acknowledge: true }, maxRetries: 0 }))
expect(wrapper.findAll('button').some(b => b.text() === 'Save private gateway connection')).toBe(false)
expect(wrapper.emitted('refresh')).toHaveLength(1)
})
it('uses the normal app installer without enabling any route', async () => {
const wrapper = mount(PublicWebGateway, { props: { gateway } })
await wrapper.findAll('button').find(b => b.text() === 'Install Public Web Router')!.trigger('click')
await flushPromises()
expect(installPublishingApp).toHaveBeenCalledWith('public-web-router')
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('requests a selected app route without supplying a raw upstream or granting guest credentials', async () => {
app.data['package-data']['public-web-router'] = { state: 'installed' }
const wrapper = mount(PublicWebGateway, { props: { gateway: { ...gateway, configured: true, domains: ['app.example'] }, app: { id: 'photoprism', name: 'PhotoPrism' } } })
await wrapper.get('input[maxlength="253"]').setValue('app.example')
await wrapper.findAll('button').find(b => b.text() === 'Connect this app through its gate')!.trigger('click')
await flushPromises()
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.gateway-app-route', params: { app_id: 'photoprism', domain: 'app.example', enabled: true }, maxRetries: 0 })
})
it('reuses saved enrollment and clearly labels test certificates', () => {
app.data['package-data']['public-web-router'] = { status: 'running' }
const wrapper = mount(PublicWebGateway, { props: { gateway: { ...gateway, configured: true, host: 'gateway.example', domains: ['site.example'], certificate_mode: 'test' } } })
expect(wrapper.text()).toContain('Ordinary browsers will not trust this route')
expect(wrapper.text()).not.toContain('Install Public Web Router')
expect(rpcClient.call).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,170 @@
vi.mock('@/services/installPublishingApp', () => ({ installPublishingApp: vi.fn() }))
import { installPublishingApp } from '@/services/installPublishingApp'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn(), verifyHttps: vi.fn() }))
const page = vi.hoisted(() => ({ name: 'external-access' }))
const appStore = vi.hoisted(() => ({ installPackage: vi.fn(), data: { 'package-data': {} as Record<string, unknown> } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => appStore }))
vi.mock('vue-router', () => ({ useRoute: () => page, useRouter: () => ({ push: vi.fn() }), RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { props: ['show'], template: '<div data-testid="ai-connection" :data-open="show" />', methods: { showRoutstr() {} } } }))
vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
import PublishingSetup from '../PublishingSetup.vue'
import { rpcClient } from '@/api/rpc-client'
const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} })
beforeEach(() => {
vi.clearAllMocks(); page.name = 'external-access'
appStore.data['package-data'] = {}
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' })
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
})
describe('publishing setup', () => {
it('offers provider setup and credit from website design without starting generation', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.text()).toContain('Use Routstr by default')
expect(wrapper.text()).not.toContain('Installed Ollama model')
await wrapper.findAll('button').find(b => b.text() === 'Routstr credit and top up')!.trigger('click')
expect(wrapper.get('[data-testid="ai-connection"]').attributes('data-open')).toBe('true')
expect(api.generate).not.toHaveBeenCalled()
expect(api.update).not.toHaveBeenCalled()
wrapper.unmount()
})
it('revokes a local nsite asset through the publishing action without announcing anything', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['nostr', 'public-web'], domain: { hostname: 'site.example' }, revisions: [], fips_publication: { html: '<h1>Public</h1>', port: 32000, nsite_asset: { html: '<h1>Reviewed</h1>', receipt: { sha256: 'a'.repeat(64), size: 17 } } } } } }, apps: [], publication_enabled: true })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
await wrapper.findAll('button').find(b => b.text() === 'Stop sharing the local nsite file')!.trigger('click')
await flushPromises()
expect(api.update).toHaveBeenCalledWith(2, { action: 'unshare-nsite-asset', id: 'site' })
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'identity.nostr-sign' }))
expect(wrapper.text()).toContain('Local nsite file sharing stopped')
wrapper.unmount()
})
it('keeps unpublish controls available when a published route is deselected', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['tor'], domain: null, revisions: [], tor_publication: { html: '<h1>Public</h1>', port: 32100 } } } }, apps: [], publication_enabled: true })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-connections"]').trigger('click')
await wrapper.get('input[value="tor"]').setValue(false)
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
expect(wrapper.findAll('button').some(button => button.text() === 'Unpublish from Tor')).toBe(true)
expect(api.update).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('reuses saved routes and advances the walkthrough without publishing or signing', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-design"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('[aria-controls="setup-step-connections"]').attributes('aria-expanded')).toBe('false')
expect(wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.attributes('disabled')).toBeDefined()
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
expect(wrapper.get('[aria-controls="setup-step-publish"]').attributes('aria-expanded')).toBe('true')
expect(api.update).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('saves before advancing and keeps failed saves on the same step', async () => {
const wrapper = mount(PublishingSetup); await flushPromises()
api.update.mockRejectedValueOnce(new Error('Connection choices could not be saved'))
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-connections"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('[role="alert"]').text()).toContain('could not be saved')
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-sharing"]').attributes('aria-expanded')).toBe('true')
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('carries the existing connection choices into a new website draft', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
api.update.mockResolvedValueOnce({ state: { ...state(), version: 3, projects: { site: { id: 'site', name: 'My website', draft: '', routes: [], domain: null, revisions: [] } } }, project_id: 'site' })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === 'Create another website')!.trigger('click'); await flushPromises()
await wrapper.get('[aria-controls="setup-step-connections"]').trigger('click')
expect((wrapper.get('input[value="fips"]').element as HTMLInputElement).checked).toBe(true)
expect(api.update).toHaveBeenCalledTimes(1)
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('checks public HTTPS only on request and clears verification when the domain changes', async () => {
page.name = 'publish-website'
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['public-web'], domain: { hostname: 'www.example.com', destination: '8.8.8.8' }, revisions: [], fips_publication: { html: '<h1>Public</h1>', port: 32000 } } } }, apps: [], fips_address: 'fd00::1', publication_enabled: true, notice: '' })
api.verifyHttps.mockResolvedValue({ hostname: 'www.example.com', sha256: 'synthetic', checked_at: '2026-10-08T00:00:00Z' })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-domain"]').trigger('click')
expect(api.verifyHttps).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === 'Check public HTTPS')!.trigger('click'); await flushPromises()
expect(api.verifyHttps).toHaveBeenCalledWith('site', 2)
expect(wrapper.text()).toContain('valid TLS and exact published content')
await wrapper.get('input[placeholder="www.yourdomain.com"]').setValue('other.example.com')
expect(wrapper.text()).not.toContain('valid TLS and exact published content')
})
it('creates only an explicit app-scoped grant and supports revocation without showing other credentials', async () => {
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [{ id: 'nextcloud', name: 'Nextcloud', port: 8080, guest_access: true }], grants: [{ id: 'external:test:Guest', label: 'Guest', apps: ['nextcloud'], expires_at: 2000000000 }], notice: '' })
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'external:test:Guest', token: 'synthetic-test-token', app_id: 'nextcloud', expires_at: 2000000000 })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-sharing"]').trigger('click')
expect(rpcClient.call).not.toHaveBeenCalled()
await wrapper.get('button[aria-haspopup="listbox"]').trigger('click')
await wrapper.get('input[role="combobox"]').setValue('not a supported app')
expect(wrapper.text()).not.toContain('Create app-only access')
expect(wrapper.text()).toContain('No matching apps')
await wrapper.get('input[role="combobox"]').setValue('Nextcloud')
await wrapper.get('[role="option"]').trigger('click')
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
expect(wrapper.text()).toContain('synthetic-test-token')
await wrapper.findAll('button').find(b => b.text() === 'Revoke access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenLastCalledWith({ method: 'publishing.access-revoke', params: { id: 'external:test:Guest' }, maxRetries: 0 })
expect(wrapper.text()).not.toContain('synthetic-test-token')
})
it('offers catalog installation and skips it when Blossom is already installed', async () => {
page.name = 'publish-website'
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('[data-testid="blossom-setup"]').text()).toContain('Install Blossom')
await wrapper.findAll('button').find(b => b.text() === 'Install Blossom')!.trigger('click'); await flushPromises()
expect(installPublishingApp).toHaveBeenCalledWith('blossom')
wrapper.unmount()
appStore.data['package-data'].blossom = { state: 'installed' }
const installed = mount(PublishingSetup); await flushPromises()
expect(installed.find('[aria-controls="setup-step-storage"]').exists()).toBe(false)
expect(installed.get('[aria-controls="setup-step-design"]').attributes('aria-expanded')).toBe('true')
expect(installed.get('[data-testid="blossom-setup"]').text()).toContain('skip installation')
expect(installed.find('a[href="/dashboard/marketplace/blossom"]').exists()).toBe(false)
})
it('loads choices from the node and saves multiple routes without activating them', async () => {
const wrapper = mount(PublishingSetup); await flushPromises()
const inputs = wrapper.findAll('input[type="checkbox"][value]')
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
await inputs[2]!.setValue(true)
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click')
await flushPromises()
expect(api.update).toHaveBeenCalledWith(2, { action: 'connections', routes: ['fips', 'tor'] })
expect(wrapper.text()).toContain('Existing app access has not changed')
})
it('keeps a failed save visible and does not pretend it succeeded', async () => {
api.update.mockRejectedValue(new Error('Reload before saving'))
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('Reload before saving')
expect(wrapper.text()).not.toContain('Connection preferences saved')
})
it('isolates saved HTML and presents Nostr as an independent choice', async () => {
page.name = 'publish-website'
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<script>parent.fetch("/rpc")</script>', routes: ['fips', 'nostr'], domain: null, revisions: [] } } }, apps: [], fips_address: 'fd00::1', publication_enabled: false, notice: 'Saving does not publish.' })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-design"]').trigger('click')
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'")
await wrapper.get('[aria-controls="setup-step-connections"]').trigger('click')
expect(wrapper.findAll('input[type="checkbox"][value]')).toHaveLength(4)
expect(wrapper.text()).toContain('reachability still needs verification')
})
})
@@ -64,8 +64,8 @@ async function apply() {
>{{ active ? 'enabled' : 'off' }}</span>
</div>
<p class="text-sm text-white/60 mb-5">
Routstr is pay-per-use AI inference, paid in sats over Cashu, used when your local
model can't take a request. It never spends without a prepaid ceiling you set here —
Routstr is pay-per-use AI inference, paid in sats from this node’s ecash wallet.
This allowance limits spending; it does not add funds to the wallet. It never spends without a ceiling you set here —
at <span class="font-mono">0</span> it is completely disabled. The assistant stops
when the ceiling is reached; raising it widens the remainder without erasing the
spend history.