fix: use setup walkthroughs and exclude legacy node signers
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user