feat: integrate local Blossom, reviewed nsites and scoped app access

This commit is contained in:
archipelago
2026-10-08 09:12:40 -04:00
parent 05e999b117
commit 28a92fcc9b
39 changed files with 2060 additions and 50 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="28" fill="#191c28"/><g fill="#dca6c6"><ellipse cx="64" cy="40" rx="17" ry="24"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(72 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(144 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(216 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(288 64 64)"/></g><circle cx="64" cy="64" r="13" fill="#f1cf86"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+13
View File
@@ -673,6 +673,19 @@
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry"
},
{
"id": "blossom",
"author": "hzrd149 / Archipelago",
"requires": [],
"tier": "optional",
"title": "Blossom",
"version": "6.4.1-archy.1",
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1",
"category": "data",
"repoUrl": "https://github.com/hzrd149/blossom-server",
"icon": "/assets/img/app-icons/blossom.svg"
}
]
}
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../publishing', () => ({ publishing: { status: vi.fn(), update: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import { publishing } from '../publishing'
import { namedNsiteUrl, prepareNsite, publishNsite, relayAddresses, retryNsite, requestNsiteDeletion, nsiteIdentities, storeLocalWebsite } from '../nsitePublishing'
import type { NsiteReceipt, SignedNsiteEvent } from '../nsitePublishing'
const identity = { id: 'profile', name: 'Me', nostr_pubkey: 'a'.repeat(64), is_node: false }
const event: SignedNsiteEvent = { id: 'b'.repeat(64), pubkey: identity.nostr_pubkey, kind: 35128, created_at: 1, tags: [['d', 'website123']], content: '', sig: 'c'.repeat(128) }
const receipt: NsiteReceipt = { identity_id: identity.id, server: 'https://blossom.example', event, accepted_relays: [], deletion_requested: false }
let accept = true
class Socket {
onopen?: () => void
onmessage?: (event: { data: string }) => void
onerror?: () => void
onclose?: () => void
constructor() { queueMicrotask(() => this.onopen?.()) }
send(raw: string) {
const sent = JSON.parse(raw)[1]
queueMicrotask(() => {
this.onmessage?.({ data: JSON.stringify(['OK', 'unrelated-id', true]) })
this.onmessage?.({ data: JSON.stringify(['OK', sent.id, accept]) })
})
}
close() {}
}
const prepared = { html: '<h1>Hello 🏝</h1>', sha256: 'd'.repeat(64), server: receipt.server, identifier: 'website123', authorization: { kind: 24242, tags: [['expiration', String(Math.floor(Date.now() / 1000) + 300)]] }, manifest: { ...event } }
async function publishReviewed(html: string) {
const p = await prepareNsite('project', 4, receipt.server, html)
return publishNsite('project', 4, identity, ['wss://relay.example'], p)
}
beforeEach(() => {
vi.clearAllMocks(); accept = true
vi.stubGlobal('WebSocket', Socket)
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4 } } as never)
vi.mocked(publishing.update).mockResolvedValue({} as never)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.nsite-prepare') return prepared as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'identity.list') return { identities: [identity, { ...identity, id: 'node', is_node: true }] } as never
throw new Error('Unexpected RPC')
})
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html)))
})
describe('named nsite publishing', () => {
it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => {
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.blossom-prepare') return { authorization: prepared.authorization } as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'publishing.blossom-store') return {} as never
throw new Error('Unexpected RPC')
})
await storeLocalWebsite('project', 4, identity)
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.blossom-prepare', 'identity.nostr-sign', 'publishing.blossom-store'])
expect(fetch).not.toHaveBeenCalled()
expect(socket).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
await expect(storeLocalWebsite('project', 4, { ...identity, is_node: true })).rejects.toThrow('profile identity')
})
it('uses profile identities and rejects insecure relay URLs', async () => {
expect(await nsiteIdentities()).toEqual([identity])
expect(relayAddresses('wss://relay.example wss://relay.example')).toEqual(['wss://relay.example/'])
for (const value of ['ws://relay.example', 'wss://user:secret@relay.example', 'wss://relay.example/#key']) expect(() => relayAddresses(value)).toThrow()
})
it('preparation never signs, uploads or broadcasts', async () => {
await prepareNsite('project', 4, receipt.server, '<h1>Private draft</h1>')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.nsite-prepare'])
})
it('refuses stale reviews before signing or uploading', async () => {
await expect(publishNsite('project', 3, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed after review')
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('uploads exact UTF-8 bytes, scopes signing to the chosen profile, and records delivery', async () => {
const result = await publishReviewed( '<meta http-equiv="refresh" content="0;url=https://tracker.example"><script>bad()</script><h1>Draft</h1>')
expect(result.accepted_relays).toEqual(['wss://relay.example'])
const prep = vi.mocked(rpcClient.call).mock.calls[0]![0].params as { html: string }
expect(prep.html).not.toContain('<script')
expect(prep.html).not.toContain('http-equiv')
expect(prep.html).toContain('<h1>Draft</h1>')
expect(fetch).toHaveBeenNthCalledWith(1, `${receipt.server}/upload`, expect.objectContaining({ method: 'PUT', credentials: 'omit', redirect: 'error', body: prepared.html }))
expect(publishing.update).toHaveBeenCalledTimes(2)
expect(vi.mocked(publishing.update).mock.calls[0]![1]).toMatchObject({ receipt: { accepted_relays: [] } })
expect(vi.mocked(publishing.update).mock.calls[1]![1]).toMatchObject({ receipt: { accepted_relays: ['wss://relay.example'] } })
const signs = vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'identity.nostr-sign')
expect(signs.every(([r]) => r.params?.id === identity.id)).toBe(true)
})
it('never pays or announces when storage requires payment', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response('', { status: 402 }))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No payment was made')
expect(publishing.update).not.toHaveBeenCalled()
})
it('rejects altered signer output before upload or relay delivery', async () => {
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'identity.nostr-sign') return { ...event, ...prepared.authorization, tags: [['t', 'upload']] } as never
throw new Error('Unexpected RPC')
})
await expect(publishNsite('project', 4, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed the reviewed event')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
})
it('bounds untrusted upload receipts before announcing', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response(' '.repeat(8193)))
await expect(publishReviewed('<h1>Draft</h1>')).rejects.toThrow('size limit')
expect(publishing.update).not.toHaveBeenCalled()
})
it('does not announce if the server changes uploaded bytes', async () => {
vi.mocked(fetch).mockReset().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }))).mockResolvedValueOnce(new Response('different'))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('different website bytes')
expect(publishing.update).not.toHaveBeenCalled()
})
it('retains a pending manifest on rejection and retries without signing or uploading', async () => {
accept = false
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No relay accepted')
vi.clearAllMocks(); accept = true
const result = await retryNsite('project', receipt, ['wss://relay.example'])
expect(result.accepted_relays).toHaveLength(1)
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('requests deletion with the publishing identity without deleting shared blobs', async () => {
await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example'])
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } }))
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).toHaveBeenCalledWith(4, expect.objectContaining({ receipt: expect.objectContaining({ deletion_requested: true }) }))
})
it('builds a portable named-site gateway URL without overwriting the root site', () => {
const url = new URL(namedNsiteUrl(receipt, 'https://gateway.example'))
expect(url.hostname.split('.')[0]).toHaveLength(50 + 'website123'.length)
expect(url.hostname).toContain('website123.gateway.example')
expect(() => namedNsiteUrl(receipt, 'http://gateway.example')).toThrow()
})
})
+151
View File
@@ -0,0 +1,151 @@
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { publishing } from './publishing'
export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string }
export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean }
export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
export function relayAddresses(raw: string): string[] {
const list = [...new Set(raw.split(/[\s,]+/).filter(Boolean).map(value => {
const url = new URL(value)
if (url.protocol !== 'wss:' || url.username || url.password || url.hash || value.length > 300) throw new Error('Use secure wss:// relay URLs without credentials or fragments')
return url.href
}))]
if (!list.length || list.length > 8) throw new Error('Choose between one and eight relays')
return list
}
export async function nsiteIdentities(): Promise<NsiteIdentity[]> {
const data = await rpcClient.call<{ identities: NsiteIdentity[] }>({ method: 'identity.list', maxRetries: 0 })
return data.identities.filter(i => !i.is_node && i.nostr_pubkey)
}
async function sign(identity: NsiteIdentity, event: Record<string, unknown>): Promise<SignedNsiteEvent> {
const signed = await rpcClient.call<SignedNsiteEvent>({ method: 'identity.nostr-sign', params: { id: identity.id, event }, maxRetries: 0 })
if (signed.pubkey !== identity.nostr_pubkey || signed.kind !== event.kind) throw new Error('Signer returned a different identity or event kind')
for (const key of ['created_at', 'content', 'tags'] as const) {
if (event[key] !== undefined && JSON.stringify(signed[key]) !== JSON.stringify(event[key])) throw new Error('Signer changed the reviewed event; this event will not be sent')
}
return signed
}
export async function storeLocalWebsite(projectId: string, version: number, identity: NsiteIdentity): Promise<void> {
if (identity.is_node) throw new Error('Choose a profile identity for local files')
const prepared = await rpcClient.call<{ authorization: Record<string, unknown> }>({ method: 'publishing.blossom-prepare', params: { id: projectId, version }, maxRetries: 0 })
const authorization = await sign(identity, prepared.authorization)
await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization }, timeout: 70000, maxRetries: 0 })
}
export function sendToRelay(url: string, event: SignedNsiteEvent): Promise<boolean> {
return new Promise(resolve => {
let socket: WebSocket
try { socket = new WebSocket(url) } catch { resolve(false); return }
let settled = false
const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.close(); resolve(ok) }
const timer = setTimeout(() => finish(false), 15000)
socket.onopen = () => socket.send(JSON.stringify(['EVENT', event]))
socket.onerror = () => finish(false)
socket.onclose = () => finish(false)
socket.onmessage = message => {
if (typeof message.data !== 'string' || message.data.length > 65536) return
try {
const reply = JSON.parse(message.data)
if (reply[0] === 'OK' && reply[1] === event.id) finish(reply[2] === true)
} catch { /* Ignore unrelated relay messages. */ }
}
})
}
async function broadcast(event: SignedNsiteEvent, relays: string[]): Promise<string[]> {
const result = await Promise.all(relays.map(async relay => ({ relay, ok: await sendToRelay(relay, event) })))
return result.filter(r => r.ok).map(r => r.relay)
}
async function record(projectId: string, receipt: NsiteReceipt): Promise<void> {
// Persist this operation's receipt without overwriting a newer draft or other
// transport. Only retry the optimistic conflict, never upload/sign/broadcast.
for (let attempt = 0; attempt < 3; attempt++) {
const status = await publishing.status()
try { await publishing.update(status.state.version, { action: 'record-nsite', id: projectId, receipt }); return }
catch (error) {
if (attempt === 2 || !(error instanceof Error) || !error.message.includes('changed')) throw error
}
}
}
async function readback(response: Response, expected: Uint8Array): Promise<void> {
if (!response.ok || !response.body) throw new Error('Uploaded website could not be fetched back')
const reader = response.body.getReader()
let offset = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (offset + value.length > expected.length || value.some((byte, index) => byte !== expected[offset + index])) throw new Error('Blossom returned different website bytes')
offset += value.length
}
if (offset !== expected.length) throw new Error('Blossom returned an incomplete website')
} finally { await reader.cancel() }
}
async function uploadDescriptor(response: Response): Promise<{ sha256: string; size: number }> {
if (!response.body) throw new Error('Blossom upload receipt is empty')
const reader = response.body.getReader()
const bytes = new Uint8Array(8192)
let size = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (size + value.length > bytes.length) throw new Error('Blossom upload receipt exceeds the size limit')
bytes.set(value, size); size += value.length
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)))
} finally { await reader.cancel() }
}
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): 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 publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise<NsiteReceipt> {
if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity')
const current = await publishing.status()
if (current.state.version !== version) throw new Error('The project changed after review. Review the publication again.')
const expiry = (p.authorization.tags as string[][] | undefined)?.find(t => t[0] === 'expiration')?.[1]
if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.')
const authorization = await sign(identity, p.authorization)
const bytes = new TextEncoder().encode(p.html)
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization))))
const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html })
if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.')
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
const descriptor = await uploadDescriptor(uploaded)
if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.')
await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes)
const event = await sign(identity, p.manifest)
const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false }
// Save the exact signed manifest before network delivery, for recovery.
await record(projectId, receipt)
const delivered = { ...receipt, accepted_relays: await broadcast(event, relays) }
await record(projectId, delivered)
if (!delivered.accepted_relays.length) throw new Error('No relay accepted the manifest. The upload and signed manifest were retained; retry delivery from this project.')
return delivered
}
export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise<NsiteReceipt> {
if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request')
const accepted = await broadcast(receipt.event, relays)
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
await record(projectId, next)
if (!accepted.length) throw new Error('No relay accepted this delivery attempt')
return next
}
export async function requestNsiteDeletion(projectId: string, receipt: NsiteReceipt, identity: NsiteIdentity, relays: string[]): Promise<void> {
if (identity.id !== receipt.identity_id || identity.nostr_pubkey !== receipt.event.pubkey) throw new Error('Choose the identity that published this nsite')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1]
if (!identifier) throw new Error('Missing named-site identifier')
const event = await sign(identity, { kind: 5, created_at: Math.floor(Date.now() / 1000), content: 'Remove this website manifest', tags: [['e', receipt.event.id], ['a', `35128:${receipt.event.pubkey}:${identifier}`], ['k', '35128']] })
const accepted = await broadcast(event, [...new Set([...receipt.accepted_relays, ...relays])])
if (!accepted.length) throw new Error('No relay accepted the deletion request. The nsite may remain available.')
await record(projectId, { ...receipt, deletion_requested: true })
}
export function namedNsiteUrl(receipt: NsiteReceipt, gateway: string): string {
const url = new URL(gateway)
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.pathname !== '/') throw new Error('Enter the gateway HTTPS origin without a path')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1] ?? ''
if (!/^[a-z0-9-]{1,13}$/.test(identifier) || identifier.endsWith('-') || !/^[a-f0-9]{64}$/.test(receipt.event.pubkey)) throw new Error('Invalid named nsite')
const author = BigInt(`0x${receipt.event.pubkey}`).toString(36).padStart(50, '0')
return `https://${author}${identifier}.${url.hostname}/`
}
+8 -1
View File
@@ -1,4 +1,5 @@
import { rpcClient } from '@/api/rpc-client'
import type { NsiteReceipt } from './nsitePublishing'
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
export interface PublishDomain { hostname: string; destination: string | null }
@@ -8,6 +9,8 @@ export interface WebsiteProject {
draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string } | null
tor_publication?: { port: number; html: string; created_at: string } | null
nsite_receipt?: NsiteReceipt | null
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
}
export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
@@ -16,12 +19,15 @@ export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_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 }[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean }[]
nostr_relays?: string[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[]
grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[]
}
export interface DnsPlan {
records: { record_type: string; name: string; value: string; ttl: number }[]
verified: boolean; notes: string[]; instructions_url: string
}
export interface HttpsCheck { hostname: string; sha256: string; checked_at: string }
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
{ id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' },
{ id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' },
@@ -30,6 +36,7 @@ export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: str
]
export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
verifyHttps: (id: string, version: number) => rpcClient.call<HttpsCheck>({ method: 'publishing.verify-https', params: { id, version }, timeout: 30000, maxRetries: 0 }),
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
method: 'publishing.update', params: { version, change }, maxRetries: 0,
}),
@@ -81,6 +81,7 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337,
'blossom': GENERATED_APP_PORTS.blossom,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
@@ -7,6 +7,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"blossom": 8191,
"botfights": 9100,
"btcpay-server": 23000,
"cuprate-ui": 18091,
@@ -53,6 +54,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI",
"blossom": "Blossom",
"botfights": "BotFights",
"btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)",
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { pendingWebsiteHtml } from '@/services/websiteImport'
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
import type { DnsPlan, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
import { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing'
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
const route = useRoute()
const appStore = useAppStore()
const blossomInstalled = computed(() => !!appStore.data?.['package-data']?.blossom)
const websiteMode = computed(() => route.name === 'publish-website')
const status = ref<PublishingStatus | null>(null)
const error = ref('')
@@ -20,8 +26,27 @@ const destination = ref('')
const prompt = ref('')
const model = ref('')
const dns = ref<DnsPlan | null>(null)
const httpsCheck = ref<HttpsCheck | null>(null)
watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = null })
const acknowledgeFips = ref(false)
const acknowledgeTor = ref(false)
const identities = ref<NsiteIdentity[]>([])
const identityId = ref('')
const blossom = ref('')
const relays = ref('')
const gateway = ref('')
const nsiteUrl = ref('')
const guestApp = ref('')
const guestLabel = ref('Guest')
const guestHours = ref(24)
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
onDeactivated(() => { issuedAccess.value = null })
onBeforeUnmount(() => { issuedAccess.value = null })
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
const acknowledgeNostr = ref(false)
const acknowledgeUpload = ref(false)
const nsiteReview = ref<{ projectId: string; version: number; identity: NsiteIdentity; relays: string[]; prepared: PreparedNsite } | null>(null)
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
const current = computed(() => status.value?.state.projects[projectId.value])
@@ -39,10 +64,12 @@ async function perform(work: () => Promise<void>) {
function selectProject(p: WebsiteProject) {
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
identityId.value = p.nsite_receipt?.identity_id ?? ''; blossom.value = p.nsite_receipt?.server ?? ''; acknowledgeNostr.value = false; nsiteUrl.value = ''
}
async function refresh() {
await perform(async () => {
status.value = await publishing.status()
if (!relays.value) relays.value = (status.value.nostr_relays ?? []).join('\n')
if (!websiteMode.value) selected.value = [...status.value.state.connections]
else if (current.value) selectProject(current.value)
else if (projects.value[0]) selectProject(projects.value[0])
@@ -81,6 +108,7 @@ async function save() {
} : { action: 'connections', routes: selected.value }
const result = await publishing.update(status.value.state.version, change)
status.value.state = result.state
if (websiteMode.value) hostname.value = current.value?.domain?.hostname ?? ''
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
})
}
@@ -127,6 +155,82 @@ async function setTorPublication(enable: boolean) {
async function prepareDns() {
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
}
async function verifyHttps() {
await perform(async () => {
httpsCheck.value = null
if (!status.value) return
httpsCheck.value = await publishing.verifyHttps(projectId.value, status.value.state.version)
})
}
async function loadIdentities() { await perform(async () => { identities.value = await nsiteIdentities() }) }
async function createGuestAccess() {
await perform(async () => {
issuedAccess.value = null
issuedAccess.value = await rpcClient.call({ method: 'publishing.access-create', params: { app_id: guestApp.value, label: guestLabel.value, hours: guestHours.value }, maxRetries: 0 })
status.value = await publishing.status()
message.value = 'App-only access created. Copy the token now; it cannot be shown again.'
})
}
async function revokeGuestAccess(id: string) {
await perform(async () => {
await rpcClient.call({ method: 'publishing.access-revoke', params: { id }, maxRetries: 0 })
if (issuedAccess.value?.id === id) issuedAccess.value = null
status.value = await publishing.status()
message.value = 'Access revoked for new requests. Content already downloaded cannot be recalled.'
})
}
async function storeLocally() {
await perform(async () => {
const identity = identities.value.find(i => i.id === identityId.value)
if (!identity || !status.value || !current.value) throw new Error('Choose a profile identity and save a draft first')
await storeLocalWebsite(projectId.value, status.value.state.version, identity)
status.value = await publishing.status()
message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.'
})
}
watch([projectId, blossom, 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)
nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared }
acknowledgeNostr.value = false; acknowledgeUpload.value = false
})
}
async function handleNsite(action: 'publish' | 'retry' | 'delete') {
await perform(async () => {
if (!status.value || !current.value || !acknowledgeNostr.value) return
const targets = relayAddresses(relays.value)
const identity = identities.value.find(i => i.id === identityId.value)
const receipt = current.value.nsite_receipt
try {
if (action === 'retry' && receipt) await retryNsite(projectId.value, receipt, targets)
else {
if (!identity) throw new Error('Load identities and choose the profile identity you want to use')
if (action === 'delete' && receipt) await requestNsiteDeletion(projectId.value, receipt, identity, targets)
else {
const review = nsiteReview.value
if (!review || !acknowledgeUpload.value) throw new Error('Review the exact upload and explicitly approve replication first')
await publishNsite(review.projectId, review.version, review.identity, review.relays, review.prepared)
}
}
} finally {
acknowledgeNostr.value = false; acknowledgeUpload.value = false; nsiteReview.value = null
// A failed relay delivery can still leave a durable upload/manifest. Show
// that receipt so retry never silently uploads or signs a second copy.
status.value = await publishing.status()
}
message.value = action === 'delete' ? 'A relay accepted the deletion request. Other relays, Blossom servers and cached copies may retain the website.' : 'The uploaded bytes were checked and a relay accepted the named-site manifest. Gateway availability still needs checking.'
})
}
async function showNsiteAddress() {
await perform(async () => {
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
})
}
function download() {
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
@@ -146,7 +250,28 @@ onMounted(refresh)
<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">
<template v-if="status">
<section v-if="websiteMode" class="glass-card p-5 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>
<RouterLink to="/dashboard/marketplace/blossom" class="glass-button inline-block px-4 py-2">Install Blossom</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">
@@ -204,7 +329,10 @@ onMounted(refresh)
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
</dl>
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. This setup is manual; the dashboard has not verified it. Removing this FIPS publication also disconnects this proxy route.</p>
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. Removing the upstream publication also disconnects this proxy route.</p>
<button class="glass-button px-4 py-2" :disabled="hostname !== current.domain?.hostname || !current.routes.includes('public-web')" @click="verifyHttps">Check public HTTPS</button>
<p v-if="httpsCheck" class="text-sm text-green-200">Verified https://{{ httpsCheck.hostname }}/ at {{ new Date(httpsCheck.checked_at).toLocaleString() }}: valid TLS and exact published content. Checked from this node; also test from an outside device.</p>
<p v-else class="text-sm text-amber-200">Public HTTPS has not been verified for these saved settings.</p>
</div>
<button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
<div v-if="dns" class="space-y-3">
@@ -220,12 +348,35 @@ onMounted(refresh)
<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">
<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>
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
<div v-if="guestTarget" class="space-y-2 text-sm">
<p v-if="status.fips_address" class="break-all">FIPS address: <a :href="`https://[${status.fips_address}]:${guestTarget.port}/`" target="_blank" rel="noopener noreferrer" class="underline">https://[{{ status.fips_address }}]:{{ guestTarget.port }}/</a></p>
<p v-if="selected.includes('public-web')">For your public reverse proxy, use the FIPS address above as its forward host and port {{ guestTarget.port }}, with upstream scheme HTTP. Keep the app gate enabled. Configure the application's public URL if it requires one.</p>
<p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p>
</div>
<button class="glass-button px-4 py-2" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button>
<div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2">
<p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p>
<code class="block break-all select-all">{{ issuedAccess.token }}</code>
<p class="text-sm">The guest opens the app address and chooses “Have an app-only access token?”. API clients can use it as an Authorization Bearer token. It cannot log in to the dashboard.</p>
<button class="underline text-sm" @click="issuedAccess = null">Hide token</button>
</div>
<div v-for="grant in status.grants ?? []" :key="grant.id" class="flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-3">
<p>{{ grant.label }} · {{ grant.apps.join(', ') }} · {{ grant.expires_at ? new Date(grant.expires_at * 1000).toLocaleString() : 'No expiry' }}</p>
<button class="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">
<h2 class="text-lg font-semibold">Publish the saved version on FIPS</h2>
<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>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || !current.routes.includes('fips') || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
<div v-if="current.fips_publication" class="text-sm space-y-2">
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
@@ -247,6 +398,41 @@ 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">
<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>
<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="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 px-4 py-2" :disabled="!identityId || !blossom || !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">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>
</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 px-4 py-2" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !blossom" @click="handleNsite('publish')">Upload and publish saved website</button>
<template v-if="current.nsite_receipt">
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
<p class="text-sm break-all">Retry destinations: {{ relays }}. Removal also contacts relays that previously accepted this manifest: {{ current.nsite_receipt.accepted_relays.join(', ') || 'none recorded' }}.</p>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeNostr || current.nsite_receipt.deletion_requested" @click="handleNsite('retry')">Retry manifest delivery</button>
<button class="glass-button px-4 py-2 ml-2" :disabled="!acknowledgeNostr || !identityId" @click="handleNsite('delete')">Request removal from relays</button>
<label class="block">Compatible nsite gateway<input v-model="gateway" class="field mt-2" placeholder="https://your-nsite-gateway.example" /></label>
<button class="glass-button px-4 py-2" :disabled="!gateway" @click="showNsiteAddress">Show browser address</button>
<a v-if="nsiteUrl" :href="nsiteUrl" target="_blank" rel="noopener noreferrer" class="block break-all underline">{{ nsiteUrl }}</a>
</template>
</section>
<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>
@@ -1,22 +1,61 @@
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() }))
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> } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => appStore }))
vi.mock('vue-router', () => ({ useRoute: () => page, 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'
import { rpcClient } from '@/api/rpc-client'
const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} })
beforeEach(() => {
vi.clearAllMocks(); page.name = 'external-access'
appStore.data['package-data'] = {}
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' })
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
})
describe('publishing setup', () => {
it('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()
expect(api.verifyHttps).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === 'Check public HTTPS')!.trigger('click'); await flushPromises()
expect(api.verifyHttps).toHaveBeenCalledWith('site', 2)
expect(wrapper.text()).toContain('valid TLS and exact published content')
await wrapper.get('input[placeholder="www.yourdomain.com"]').setValue('other.example.com')
expect(wrapper.text()).not.toContain('valid TLS and exact published content')
})
it('creates only an explicit app-scoped grant and supports revocation without showing other credentials', async () => {
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [{ id: 'nextcloud', name: 'Nextcloud', port: 8080, guest_access: true }], grants: [{ id: 'external:test:Guest', label: 'Guest', apps: ['nextcloud'], expires_at: 2000000000 }], notice: '' })
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'external:test:Guest', token: 'synthetic-test-token', app_id: 'nextcloud', expires_at: 2000000000 })
const wrapper = mount(PublishingSetup); await flushPromises()
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()
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
expect(wrapper.text()).toContain('synthetic-test-token')
await wrapper.findAll('button').find(b => b.text() === 'Revoke access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenLastCalledWith({ method: 'publishing.access-revoke', params: { id: 'external:test:Guest' }, maxRetries: 0 })
expect(wrapper.text()).not.toContain('synthetic-test-token')
})
it('offers catalog installation and skips it when Blossom is already installed', async () => {
page.name = 'publish-website'
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('[data-testid="blossom-setup"]').text()).toContain('Install Blossom')
wrapper.unmount()
appStore.data['package-data'].blossom = { state: 'installed' }
const installed = mount(PublishingSetup); await flushPromises()
expect(installed.get('[data-testid="blossom-setup"]').text()).toContain('skip installation')
expect(installed.find('a[href="/dashboard/marketplace/blossom"]').exists()).toBe(false)
})
it('loads choices from the node and saves multiple routes without activating them', async () => {
const wrapper = mount(PublishingSetup); await flushPromises()
const inputs = wrapper.findAll('input[type="checkbox"]')
const inputs = wrapper.findAll('input[type="checkbox"][value]')
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
await inputs[2]!.setValue(true)
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click')
@@ -37,7 +76,7 @@ describe('publishing setup', () => {
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'")
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(4)
expect(wrapper.findAll('input[type="checkbox"][value]')).toHaveLength(4)
expect(wrapper.text()).toContain('reachability still needs verification')
})
})