Complete private gateway and local website publishing UAT

This commit is contained in:
archipelago
2026-10-08 18:10:41 -04:00
parent 768e828246
commit 3ab4162a8b
38 changed files with 2232 additions and 86 deletions
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import { installPublishingApp } from '../installPublishingApp'
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
describe('Setup catalogue installation', () => {
it('supplies the signed build tag and version required by package.install', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { 'public-web-router': { version: '0.1.0', manifest: { app: { id: 'public-web-router', container: { build: { tag: 'localhost/archipelago-public-web-router:0.1.0' } } } } } } }) }))
await installPublishingApp('public-web-router')
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'package.install', params: { id: 'public-web-router', dockerImage: 'localhost/archipelago-public-web-router:0.1.0', version: '0.1.0' }, timeout: 600000, maxRetries: 0 })
})
it('does not install from an unavailable or mismatched catalogue', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(installPublishingApp('blossom')).rejects.toThrow('unavailable')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { blossom: { version: '1', image: 'localhost/test:1', manifest: { app: { id: 'other' } } } } }) }))
await expect(installPublishingApp('blossom')).rejects.toThrow('not available')
expect(rpcClient.call).not.toHaveBeenCalled()
})
})
@@ -32,7 +32,7 @@ async function publishReviewed(html: string) {
beforeEach(() => {
vi.clearAllMocks(); accept = true
vi.stubGlobal('WebSocket', Socket)
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4 } } as never)
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: {} } } as never)
vi.mocked(publishing.update).mockResolvedValue({} as never)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.nsite-prepare') return prepared as never
@@ -43,6 +43,17 @@ beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html)))
})
describe('named nsite publishing', () => {
it('publishes local Blossom bytes through the node adapter and reads the public hash before announcing', async () => {
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async request => request.method === 'publishing.blossom-store' ? {} as never : original(request))
vi.mocked(fetch).mockReset().mockResolvedValue(new Response(prepared.html))
await publishNsite('project', 4, identity, ['wss://relay.example'], { ...prepared, local: true })
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'publishing.blossom-store', params: expect.objectContaining({ nsite: { html: prepared.html, server: prepared.server, acknowledge_public: true } }) }))
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith(`${prepared.server}/${prepared.sha256}`, expect.objectContaining({ credentials: 'omit', redirect: 'error' }))
expect(publishing.update).toHaveBeenCalledTimes(2)
})
it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => {
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
@@ -135,6 +146,15 @@ describe('named nsite publishing', () => {
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('does not retry announcements after local file sharing is revoked', async () => {
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: { project: { domain: { hostname: 'blossom.example' }, fips_publication: {} } } } } as never)
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
await expect(retryNsite('project', receipt, ['wss://relay.example'])).rejects.toThrow('no longer shared')
expect(socket).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
})
it('requests deletion with the publishing identity without deleting shared blobs', async () => {
await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example'])
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } }))
@@ -0,0 +1,14 @@
import { rpcClient } from '@/api/rpc-client'
import type { SignedAppCatalog } from '@/views/discover/curatedApps'
/** Setup uses the same verified catalogue and package installer as Apps. */
export async function installPublishingApp(id: 'blossom' | 'public-web-router') {
const response = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error('The trusted app catalogue is unavailable. Try again from Apps.')
const catalog = await response.json() as SignedAppCatalog
const entry = catalog.apps?.[id]
const app = entry?.manifest?.app
const dockerImage = entry?.image || app?.container?.image || app?.container?.build?.tag
if (!entry?.version || app?.id !== id || !dockerImage) throw new Error('This app is not available in the trusted catalogue yet.')
return rpcClient.call({ method: 'package.install', params: { id, dockerImage, version: entry.version }, timeout: 600000, maxRetries: 0 })
}
+24 -9
View File
@@ -5,7 +5,7 @@ import { publishing } from './publishing'
export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string }
export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean }
export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
export interface PreparedNsite { local?: boolean; html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
// Match the platform app signer and its derived Blossom upload allowlist,
// including older node records that do not carry the explicit is_node flag.
@@ -105,8 +105,8 @@ async function uploadDescriptor(response: Response): Promise<{ sha256: string; s
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)))
} finally { await reader.cancel() }
}
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): Promise<PreparedNsite> {
return await rpcClient.call<PreparedNsite>({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 })
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string, local = false): Promise<PreparedNsite> {
return await rpcClient.call<PreparedNsite>({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, local, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 })
}
export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise<NsiteReceipt> {
if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity')
@@ -116,12 +116,17 @@ export async function publishNsite(projectId: string, version: number, identity:
if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.')
const authorization = await sign(identity, p.authorization)
const bytes = new TextEncoder().encode(p.html)
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization))))
const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html })
if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.')
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
const descriptor = await uploadDescriptor(uploaded)
if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.')
if (p.local) {
await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization,
nsite: { html: p.html, server: p.server, acknowledge_public: true } }, timeout: 70000, maxRetries: 0 })
} else {
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization))))
const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html })
if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.')
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
const descriptor = await uploadDescriptor(uploaded)
if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.')
}
await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes)
const event = await sign(identity, p.manifest)
const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false }
@@ -134,6 +139,16 @@ export async function publishNsite(projectId: string, version: number, identity:
}
export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise<NsiteReceipt> {
if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request')
const status = await publishing.status()
const project = status.state.projects[projectId]
// For this node's origin, revoking the public asset must also stop retries.
// An external server is outside the node's control and retains its own copy.
if (project?.domain && receipt.server === `https://${project.domain.hostname}`) {
const asset = project.fips_publication?.nsite_asset
const digest = receipt.event.tags.find(t => t[0] === 'path' && t[1] === '/index.html')?.[2]
if (!asset || asset.receipt.sha256 !== digest) throw new Error('The local nsite file is no longer shared. Review and publish it again first.')
await readback(await fetch(`${receipt.server}/${digest}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), new TextEncoder().encode(asset.html))
}
const accepted = await broadcast(receipt.event, relays)
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
await record(projectId, next)
+3 -2
View File
@@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string
export interface WebsiteProject {
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null
nsite_receipt?: NsiteReceipt | null
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
}
@@ -19,6 +19,7 @@ export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
gateway?: { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string }
nostr_relays?: string[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[]
grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[]
+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)
@@ -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)
+2 -2
View File
@@ -67,7 +67,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 }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
@@ -92,7 +92,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>
@@ -3,9 +3,11 @@ import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from
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'
@@ -41,6 +43,8 @@ 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('')
@@ -229,7 +233,7 @@ async function verifyHttps() {
}
async function installBlossom() {
await perform(async () => {
await appStore.installPackage('blossom', '', 'latest')
await installPublishingApp('blossom')
message.value = 'Blossom installation requested through the app catalogue. This step will be skipped when installation is recorded.'
})
}
@@ -259,14 +263,14 @@ async function storeLocally() {
message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.'
})
}
watch([projectId, blossom, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true })
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, blossom.value, current.value.draft)
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
})
@@ -297,6 +301,14 @@ async function handleNsite(action: 'publish' | 'retry' | 'delete') {
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)
@@ -339,6 +351,7 @@ onMounted(refresh)
</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>
@@ -446,6 +459,7 @@ onMounted(refresh)
<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>
@@ -479,6 +493,7 @@ onMounted(refresh)
<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">
@@ -545,28 +560,35 @@ onMounted(refresh)
</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">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>
<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="block">Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
<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 || !blossom || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
<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">Upload destination: {{ nsiteReview.prepared.server }}</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>I approve sending these exact website bytes to this Blossom server.</span></label>
<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 || !blossom" @click="handleNsite('publish')">Upload and publish saved website</button>
<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>
@@ -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()
})
})
@@ -1,3 +1,5 @@
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() }))
@@ -32,6 +34,20 @@ describe('publishing setup', () => {
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' }
@@ -114,7 +130,7 @@ describe('publishing setup', () => {
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')
expect(installPublishingApp).toHaveBeenCalledWith('blossom')
wrapper.unmount()
appStore.data['package-data'].blossom = { state: 'installed' }
const installed = mount(PublishingSetup); await flushPromises()