feat: add isolated static website setup with FIPS and Tor publishing

This commit is contained in:
archipelago
2026-10-08 06:25:16 -04:00
parent c57119e9a7
commit 05e999b117
32 changed files with 2266 additions and 31 deletions
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, type Ref } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/router', () => ({ default: { push: vi.fn() } }))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
@@ -22,6 +24,8 @@ import { ContextBroker } from '../contextBroker'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { rpcClient } from '@/api/rpc-client'
import { fileBrowserClient } from '@/api/filebrowser-client'
import router from '@/router'
import { pendingWebsiteHtml } from '../websiteImport'
describe('ContextBroker', () => {
let broker: ContextBroker
@@ -31,6 +35,7 @@ describe('ContextBroker', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
pendingWebsiteHtml.value = null
mockPostMessage = vi.fn()
iframeRef = ref<HTMLIFrameElement | null>({
@@ -46,6 +51,23 @@ describe('ContextBroker', () => {
expect(broker).toBeDefined()
})
it('only accepts website drafts from the registered AIUI frame and origin', async () => {
const receive = (origin: string, source: MessageEventSource | null) => {
;(broker as unknown as { handleMessage(event: MessageEvent): void }).handleMessage(new MessageEvent('message', {
origin, source, data: { type: 'action:request', id: 'website-draft', action: 'prepare-website', params: { html: '<h1>Draft</h1>' } },
}))
}
receive('https://untrusted.example', iframeRef.value!.contentWindow)
receive('http://localhost:8100', window)
expect(pendingWebsiteHtml.value).toBeNull()
expect(router.push).not.toHaveBeenCalled()
receive('http://localhost:8100', iframeRef.value!.contentWindow)
await vi.waitFor(() => expect(router.push).toHaveBeenCalledWith('/dashboard/setup/website'))
expect(pendingWebsiteHtml.value).toBe('<h1>Draft</h1>')
expect(rpcClient.call).not.toHaveBeenCalled()
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'action:response', id: 'website-draft', success: true }), expect.any(String))
})
it('start registers message listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
broker.start()
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { PUBLISH_ROUTES, publishing, websitePreview } from '../publishing'
import { rpcClient } from '@/api/rpc-client'
describe('publishing trust boundaries', () => {
it('places restrictive CSP before untrusted website content', () => {
const hostile = '<script>fetch("/rpc")</script><meta http-equiv="Content-Security-Policy" content="default-src *">'
const preview = websitePreview(hostile)
expect(preview.indexOf("default-src 'none'")).toBeLessThan(preview.indexOf(hostile))
expect(preview).toContain("form-action 'none'")
expect(preview).not.toContain("script-src 'unsafe-inline'")
})
it('supports all four routes without Tailscale', () => {
expect(PUBLISH_ROUTES.map(r => r.id)).toEqual(['fips', 'public-web', 'tor', 'nostr'])
})
it('does not retry ambiguous writes and carries the node version', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ state: { version: 8 }, project_id: null })
await publishing.update(7, { action: 'connections', routes: ['fips', 'tor'] })
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.update', params: { version: 7, change: { action: 'connections', routes: ['fips', 'tor'] } }, maxRetries: 0 })
})
})
@@ -0,0 +1,14 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { pendingWebsiteHtml, prepareWebsiteImport } from '../websiteImport'
beforeEach(() => { pendingWebsiteHtml.value = null })
describe('AIUI website handoff', () => {
it('holds HTML only in memory for explicit import', () => {
expect(prepareWebsiteImport('<h1>My page</h1>')).toBe(true)
expect(pendingWebsiteHtml.value).toBe('<h1>My page</h1>')
})
it('rejects invalid or oversized content without destroying a pending draft', () => {
prepareWebsiteImport('existing')
for (const bad of [null, {}, '', '\0', 'a'.repeat(512 * 1024 + 1)]) expect(prepareWebsiteImport(bad)).toBe(false)
expect(pendingWebsiteHtml.value).toBe('existing')
})
})
+10
View File
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { prepareWebsiteImport } from '@/services/websiteImport'
import type {
AIUIRequest,
ArchyResponse,
@@ -237,6 +238,7 @@ export class ContextBroker {
this.handleContextRequest(msg.id, msg.category, msg.query)
break
case 'action:request':
if (msg.action === 'prepare-website' && event.source !== this.iframe.value?.contentWindow) return
this.handleActionRequest(msg.id, msg.action, msg.params)
break
case 'theme:request':
@@ -640,6 +642,14 @@ export class ContextBroker {
try {
switch (action) {
case 'prepare-website':
if (prepareWebsiteImport(params?.html)) {
void import('@/router').then(({ default: router }) => router.push('/dashboard/setup/website'))
success = true
} else {
error = 'Provide a nonempty HTML draft up to 512 KiB'
}
break
case 'navigate':
if (params.path) {
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
+47
View File
@@ -0,0 +1,47 @@
import { rpcClient } from '@/api/rpc-client'
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
export interface PublishDomain { hostname: string; destination: string | null }
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 } | null
tor_publication?: { port: number; html: string; created_at: string } | null
}
export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
}
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 }[]
}
export interface DnsPlan {
records: { record_type: string; name: string; value: string; ttl: number }[]
verified: boolean; notes: string[]; instructions_url: 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.' },
{ id: 'tor', title: 'Tor', description: 'An onion address controlled by your node. Visitors use Tor Browser.' },
{ id: 'nostr', title: 'Nostr / nsites', description: 'Publish a static website through Nostr and Blossom. Public copies may remain after you unpublish.' },
]
export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
method: 'publishing.update', params: { version, change }, maxRetries: 0,
}),
dns: (domain: PublishDomain) => rpcClient.call<DnsPlan>({ method: 'publishing.dns', params: { ...domain }, maxRetries: 0 }),
generate: (prompt: string, model: string) => rpcClient.call<{html: string; provider: string}>({
method: 'publishing.generate', params: { prompt, model }, timeout: 150000, maxRetries: 0,
}),
}
// This document is also sandboxed with no allow-* tokens by its iframe. The CSP
// precedes model content and cannot be relaxed by a second meta tag. No fetches,
// scripts, forms, navigation of the parent, cookies or management origin access.
export function websitePreview(html: string): string {
return '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src \'none\'; base-uri \'none\'; form-action \'none\'"><meta name="referrer" content="no-referrer"></head><body>' + html + '</body></html>'
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowRef } from 'vue'
// In-memory handoff only. Receiving model content never writes files, opens a
// route or publishes. The trusted setup screen requires an explicit import.
export const pendingWebsiteHtml = shallowRef<string | null>(null)
export function prepareWebsiteImport(html: unknown): boolean {
if (typeof html !== 'string' || !html.trim() || new TextEncoder().encode(html).length > 512 * 1024 || html.includes('\0')) return false
pendingWebsiteHtml.value = html
return true
}