feat: add isolated static website setup with FIPS and Tor publishing
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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>'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user