fix: use setup walkthroughs and exclude legacy node signers

This commit is contained in:
archipelago
2026-10-08 10:02:26 -04:00
parent 28a92fcc9b
commit 779d4d66e1
7 changed files with 270 additions and 54 deletions
+37 -7
View File
@@ -69,12 +69,38 @@ run only through `scripts/test-backend-isolated.sh`; use a worktree-local target
### Current integration checkpoint
The operator reaffirmed the existing Setup walkthrough design during UAT.
The follow-up UI uses the existing numbered goal cards, progress styling and
Back/Continue navigation, with one expanded step. Previously saved connections
are reused; installed Blossom omits the installation step. Blossom installation
uses the normal app-store installer. Navigation itself never saves, signs or
publishes. This UI revision is now active on Framework. The actual dashboard walkthrough
saved the synthetic draft, archived it in local Blossom with a profile identity,
fetched it back to verify exact bytes, and published it through FIPS port 32000.
The 390-pixel mobile layout passed the overflow check. Browser request monitoring
recorded no external requests during this journey.
Live archive acceptance exposed an older `node-*` identity whose `is_node` flag
was false. The container correctly rejected its upload because the canonical
signer allowlist excludes legacy node records. The website identity filter now
matches that rule, including node-name fallbacks and public-key validation, and
checks it again before signing. No public Nostr event or external replica was
created during this failure. The selected 20-test suite covers the regression and
walkthrough navigation; the production typecheck and Vite build passed. A further
new-project connection-inheritance check passed, giving eight Setup and thirteen
Nostr tests for the revised UI.
Blossom is installed and healthy on Framework through the normal app installer.
Protocol, real HTTP/HTTPS tab signing and lifecycle/data-preservation evidence is
recorded in `apps/blossom/README.md`. The combined dashboard/backend candidate has
not yet been deployed. No public Nostr test events or external file replicas have
been created. The temporary public proxy route and certificate were removed after
their standalone acceptance checks.
recorded in `apps/blossom/README.md`. The combined dashboard/backend candidate
from local commit `28a92fcc` is now deployed privately on Framework. The
authenticated publishing status probe passes; native Bitcoin/LND process IDs and
start times are unchanged. Complete browser acceptance is still in progress. No public Nostr test events or external file replicas have
been created. The earlier standalone proxy route/certificate were removed. A new owned UAT
route now connects the dashboard-published synthetic site to
`https://free.archipelago.builders` through Yaya. Trusted TLS, exact page bytes,
`/rpc` returning 404, and traversal rejection (400) pass. The route remains for UAT;
full revoke/restart qualification is still in progress.
New source work includes local Blossom website archives, explicit app-only guest
credentials, and an on-demand HTTPS check against exact published page bytes.
@@ -93,15 +119,19 @@ remain unfinished. Source validation and standalone routes must not be described
as acceptance of those features or of the complete dashboard journey.
The current dashboard production build and supported AIUI build both pass and
are staged separately on Framework. The original backend and full web tree are
backed up for rollback; the live dashboard has not been switched. The selected
are activated on Framework. The original backend and full web tree remain
backed up for rollback. The selected
dashboard suite passed 36 tests; subsequent HTTPS UI coverage passed six tests,
and tightened Nostr signing/receipt coverage passed 12 tests. The latest combined
18-test run, TypeScript check and dashboard rebuild passed. Catalog drift is zero
(37 catalog entries, 64 manifests). Full isolated backend validation now passes
1,699 tests, zero failures and four explicit ignores. The focused app-gate run
passes 53 tests, and all three credential tests pass. The deployable backend build
is still pending at this checkpoint; passing tests is not live-node acceptance.
passed. Its stripped deployment artifact SHA-256 is
`11e571a7636779d7a956f9e98dab951f19de12262cf89e5ea478cdc8ba864eae`.
Passing tests and the initial authenticated activation probe do not establish
complete live-node acceptance. The private catalogue signing ceremony remains
pending; six app-sharing policies have not yet been activated.
## Development evidence (2026-10-08, not release acceptance)
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
export interface SetupStep { id: string; title: string; description: string; complete: boolean }
const props = defineProps<{ steps: SetupStep[]; busy?: boolean }>()
const active = defineModel<string>({ required: true })
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 ?? ''
})
function move(offset: number) {
const step = props.steps[index.value + offset]
if (step) active.value = step.id
}
</script>
<template>
<div>
<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-6 min-w-0"><slot :name="step.id" /></fieldset>
<div class="flex flex-wrap justify-between gap-3 mt-6 pt-4 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 ml-auto" :disabled="busy" @click="move(1)">{{ step.id === 'storage' ? 'Continue without installing' : 'Continue' }}</button>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
@@ -64,6 +64,19 @@ describe('named nsite publishing', () => {
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()
+10 -2
View File
@@ -7,6 +7,13 @@ export interface NsiteReceipt { identity_id: string; server: string; event: Sign
export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
export interface PreparedNsite { 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)
@@ -18,9 +25,10 @@ export function relayAddresses(raw: string): string[] {
}
export async function nsiteIdentities(): Promise<NsiteIdentity[]> {
const data = await rpcClient.call<{ identities: NsiteIdentity[] }>({ method: 'identity.list', maxRetries: 0 })
return data.identities.filter(i => !i.is_node && i.nostr_pubkey)
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) {
@@ -29,7 +37,7 @@ async function sign(identity: NsiteIdentity, event: Record<string, unknown>): Pr
return signed
}
export async function storeLocalWebsite(projectId: string, version: number, identity: NsiteIdentity): Promise<void> {
if (identity.is_node) throw new Error('Choose a profile identity for local files')
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 })
@@ -79,7 +79,7 @@ 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,
}
+127 -42
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { pendingWebsiteHtml } from '@/services/websiteImport'
@@ -9,6 +9,10 @@ import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProjec
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'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const blossomInstalled = computed(() => !!appStore.data?.['package-data']?.blossom)
@@ -53,6 +57,28 @@ 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 routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr'))
async function perform(work: () => Promise<void>) {
@@ -73,14 +99,23 @@ async function refresh() {
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]!)
if (result.project_id) { selectProject(result.state.projects[result.project_id]!); selected.value = choices }
message.value = 'Website project created on your node.'
})
}
@@ -98,6 +133,14 @@ async function importFromAiui() {
}
})
}
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
@@ -162,6 +205,12 @@ async function verifyHttps() {
httpsCheck.value = await publishing.verifyHttps(projectId.value, status.value.state.version)
})
}
async function installBlossom() {
await perform(async () => {
await appStore.installPackage('blossom', '', 'latest')
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 () => {
@@ -240,18 +289,34 @@ onMounted(refresh)
</script>
<template>
<main class="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<RouterLink to="/dashboard?tab=setup" class="text-sm text-white/60 hover:text-white">← Setup</RouterLink>
<div class="flex items-start justify-between gap-4">
<div><h1 class="text-2xl font-semibold">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1>
<p class="text-white/60 mt-2">{{ 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>
<main class="pb-6">
<BackButton label="Back to Setup" desktop-margin="mb-6" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" />
<div class="flex 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 px-4 py-2" :disabled="busy" @click="refresh">Reload</button>
</div>
<p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p>
<p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p>
<p v-if="busy" role="status" class="text-white/60">Working…</p>
<template v-if="status">
<section v-if="websiteMode" class="glass-card p-5 space-y-3" data-testid="blossom-setup">
<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>
<SetupWalkthrough v-if="status" v-model="walkthroughStep" :steps="walkthroughSteps" :busy="busy">
<template #connections>
<section class="space-y-4">
<h2 class="text-lg font-semibold">Where should it be available?</h2>
<p class="text-sm text-white/60">Choose any combination. Each connection will be checked separately.</p>
<label v-for="option in routes" :key="option.id" class="flex items-start gap-3 rounded-xl border border-white/10 p-4 cursor-pointer">
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
<span><span class="font-medium">{{ option.title }}</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>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'A local FIPS address exists. This does not yet verify a website or app route.' : 'No local FIPS address detected. FIPS must be connected before its routes can be verified.' }}</p>
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
</section>
<button class="glass-button px-5 py-3" :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 #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>
@@ -267,20 +332,21 @@ onMounted(refresh)
</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>
<RouterLink to="/dashboard/marketplace/blossom" class="glass-button inline-block px-4 py-2">Install Blossom</RouterLink>
<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>
<div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div>
<fieldset :disabled="busy" class="space-y-6">
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3">
</template>
<template #design>
<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>
<button class="glass-button px-4 py-2" @click="importFromAiui">Import into a new website</button>
<button class="text-sm underline ml-3" @click="pendingWebsiteHtml = null">Discard import</button>
</section>
<section v-if="websiteMode" class="glass-card p-5 space-y-4">
<section v-if="websiteMode" class="space-y-4">
<h2 class="text-lg font-semibold">Your websites</h2>
<div v-if="projects.length" class="flex flex-wrap gap-2">
<button v-for="p in projects" :key="p.id" class="glass-button px-3 py-2" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button>
@@ -288,8 +354,10 @@ onMounted(refresh)
<label class="block">Website name<input v-model="name" maxlength="100" class="field mt-2" /></label>
<button class="glass-button px-4 py-2" @click="create">Create another website</button>
</section>
<section v-if="websiteMode && current" class="glass-card p-5 space-y-4">
<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">Open AIUI to create a website</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>
<p class="text-sm text-white/60">Generate a simple page with a model installed on this node, or paste HTML from AIUI. Generation stays on your node.</p>
<label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label>
<label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label>
@@ -299,19 +367,36 @@ onMounted(refresh)
<p class="text-xs text-white/50">Preview blocks scripts, forms and external requests. It cannot access your dashboard.</p>
<button class="glass-button px-4 py-2" :disabled="!html" @click="download">Download HTML</button>
</section>
<section class="glass-card p-5 space-y-4">
<h2 class="text-lg font-semibold">Where should it be available?</h2>
<p class="text-sm text-white/60">Choose any combination. Each connection will be checked separately.</p>
<label v-for="option in routes" :key="option.id" class="flex items-start gap-3 rounded-xl border border-white/10 p-4 cursor-pointer">
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
<span><span class="font-medium">{{ option.title }}</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>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'A local FIPS address exists. This does not yet verify a website or app route.' : 'No local FIPS address detected. FIPS must be connected before its routes can be verified.' }}</p>
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
<section v-if="websiteMode && blossomInstalled" 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 px-4 py-2" :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 px-4 py-2" :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>
<section v-if="websiteMode && publicName" class="glass-card p-5 space-y-4">
<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 justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
</section>
<button class="glass-button px-5 py-3" :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 inline-block px-4 py-2">Buy a domain through Mynymbox ↗</a>
@@ -343,12 +428,15 @@ onMounted(refresh)
<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>
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
<button class="glass-button px-5 py-3" :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 #sharing>
<section v-if="!websiteMode" class="space-y-3">
<h2 class="text-lg font-semibold">Existing app access</h2>
<p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p>
<ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul>
</section>
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
<section v-if="!websiteMode" class="space-y-3">
<h2 class="text-lg font-semibold">Grant access to an app</h2>
<p 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>
<label class="block">Application<select v-model="guestApp" class="field mt-2"><option value="">Choose an application</option><option v-for="app in shareableApps" :key="app.id" :value="app.id">{{ app.name }}</option></select></label>
@@ -371,8 +459,9 @@ onMounted(refresh)
<button class="underline text-sm" @click="revokeGuestAccess(grant.id)">Revoke access</button>
</div>
</section>
<button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button>
<section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3">
</template>
<template #publish>
<section v-if="websiteMode && current && status.publication_enabled" class="space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on FIPS or your proxy</h2>
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</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>
@@ -385,7 +474,7 @@ onMounted(refresh)
<p class="text-amber-200">External access is not verified. The FIPS firewall must allow this website’s port, {{ current.fips_publication.port }}.</p>
</div>
</section>
<section v-if="websiteMode && current && status.publication_enabled && selected.includes('tor')" class="glass-card p-5 space-y-3">
<section v-if="websiteMode && current && status.publication_enabled && selected.includes('tor')" 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>
@@ -398,7 +487,7 @@ onMounted(refresh)
<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')" class="glass-card p-5 space-y-3">
<section v-if="websiteMode && current && selected.includes('nostr')" class="space-y-3">
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
<p class="text-sm text-white/60">Upload a public static copy to a 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 px-4 py-2" @click="loadIdentities">Load signing identities</button>
@@ -433,12 +522,8 @@ onMounted(refresh)
<a v-if="nsiteUrl" :href="nsiteUrl" target="_blank" rel="noopener noreferrer" class="block break-all underline">{{ nsiteUrl }}</a>
</template>
</section>
<section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 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 justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
</section>
</fieldset>
</template>
</template>
</SetupWalkthrough>
</main>
</template>
@@ -2,9 +2,9 @@ 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(() => ({ data: { 'package-data': {} as Record<string, unknown> } }))
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, RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
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('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
import PublishingSetup from '../PublishingSetup.vue'
@@ -18,11 +18,34 @@ beforeEach(() => {
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
})
describe('publishing setup', () => {
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')
await wrapper.findAll('button').find(b => b.text() === 'Continue')!.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('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)
@@ -34,6 +57,7 @@ describe('publishing setup', () => {
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('select').setValue('nextcloud')
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
@@ -47,9 +71,13 @@ describe('publishing setup', () => {
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(appStore.installPackage).toHaveBeenCalledWith('blossom', '', 'latest')
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)
})
@@ -74,8 +102,10 @@ describe('publishing setup', () => {
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')
})