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
+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.