diff --git a/product-source/guanghu-knowledge-base/server/domain-access.test.ts b/product-source/guanghu-knowledge-base/server/domain-access.test.ts new file mode 100644 index 0000000..33b50fd --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/domain-access.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { evaluateDomainAccess } from './domain-access.js'; + +const NOW = 1_786_291_200_000; +const DIGEST = 'a'.repeat(64); + +test('a local workspace remains available without claiming domain runtime access', () => { + const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '' }, NOW); + assert.equal(status.localWorkspaceAllowed, true); + assert.equal(status.runtimeReady, false); + assert.equal(status.stage, 'login-required'); +}); + +test('a verified Forgejo account is not a domain runtime session', () => { + const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY' }, NOW); + assert.equal(status.stage, 'identity-verified'); + assert.deepEqual(status.blockers, [ + 'verified_domain_manifest_missing', + 'scoped_session_capability_missing', + 'matching_connection_receipt_missing', + ]); +}); + +test('runtime access requires matching manifest, scoped capability and online receipt', () => { + const status = evaluateDomainAccess({ + accountVerified: true, + connectionReceipt: { domainId: 'DOM-FIFTH-0001', manifestDigest: DIGEST, nodeId: 'LOCAL-001', state: 'online' }, + domainId: 'DOM-FIFTH-0001', + manifest: { digest: DIGEST, domainId: 'DOM-FIFTH-0001', verified: true }, + nodeId: 'LOCAL-001', + sessionCapability: { domainId: 'DOM-FIFTH-0001', expiresAt: NOW + 60_000, nodeId: 'LOCAL-001', scopes: ['domain:enter'] }, + }, NOW); + assert.equal(status.runtimeReady, true); + assert.equal(status.stage, 'runtime-ready'); + assert.deepEqual(status.blockers, []); +}); + +test('mismatched or expired evidence fails closed', () => { + const status = evaluateDomainAccess({ + accountVerified: true, + connectionReceipt: { domainId: 'DOMAIN-OTHER', manifestDigest: DIGEST, nodeId: 'LOCAL-001', state: 'online' }, + domainId: 'DOM-FIFTH-0001', + manifest: { digest: DIGEST, domainId: 'DOM-FIFTH-0001', verified: true }, + nodeId: 'LOCAL-001', + sessionCapability: { domainId: 'DOM-FIFTH-0001', expiresAt: NOW, nodeId: 'LOCAL-001', scopes: ['domain:enter'] }, + }, NOW); + assert.equal(status.runtimeReady, false); + assert.ok(status.blockers.includes('scoped_session_capability_missing')); + assert.ok(status.blockers.includes('matching_connection_receipt_missing')); +}); diff --git a/product-source/guanghu-knowledge-base/server/domain-access.ts b/product-source/guanghu-knowledge-base/server/domain-access.ts new file mode 100644 index 0000000..7bf9efe --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/domain-access.ts @@ -0,0 +1,50 @@ +export interface DomainAccessEvidence { + accountVerified: boolean; + connectionReceipt?: { domainId: string; manifestDigest: string; nodeId: string; state: 'online' }; + domainId: string; + manifest?: { digest: string; domainId: string; verified: boolean }; + nodeId: string; + sessionCapability?: { domainId: string; expiresAt: number; nodeId: string; scopes: string[] }; +} + +export interface DomainAccessStatus { + blockers: string[]; + domainId: string; + localWorkspaceAllowed: true; + nodeId: string; + runtimeReady: boolean; + stage: 'login-required' | 'identity-verified' | 'runtime-ready'; +} + +export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.now()): DomainAccessStatus { + const blockers: string[] = []; + if (!evidence.accountVerified || !evidence.nodeId) blockers.push('account_node_identity_missing'); + if (!evidence.manifest?.verified || evidence.manifest.domainId !== evidence.domainId || !evidence.manifest.digest) { + blockers.push('verified_domain_manifest_missing'); + } + const capability = evidence.sessionCapability; + if (!capability + || capability.domainId !== evidence.domainId + || capability.nodeId !== evidence.nodeId + || capability.expiresAt <= now + || !capability.scopes.includes('domain:enter')) { + blockers.push('scoped_session_capability_missing'); + } + const receipt = evidence.connectionReceipt; + if (!receipt + || receipt.state !== 'online' + || receipt.domainId !== evidence.domainId + || receipt.nodeId !== evidence.nodeId + || receipt.manifestDigest !== evidence.manifest?.digest) { + blockers.push('matching_connection_receipt_missing'); + } + const runtimeReady = blockers.length === 0; + return { + blockers, + domainId: evidence.domainId, + localWorkspaceAllowed: true, + nodeId: evidence.nodeId, + runtimeReady, + stage: runtimeReady ? 'runtime-ready' : evidence.accountVerified && Boolean(evidence.nodeId) ? 'identity-verified' : 'login-required', + }; +} diff --git a/product-source/guanghu-knowledge-base/src/App.tsx b/product-source/guanghu-knowledge-base/src/App.tsx index 4c50692..3727ca5 100644 --- a/product-source/guanghu-knowledge-base/src/App.tsx +++ b/product-source/guanghu-knowledge-base/src/App.tsx @@ -40,6 +40,12 @@ interface ServerProfile { channelSubtitle?: string; } +interface DomainAccessStatus { + blockers: string[]; + runtimeReady: boolean; + stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready'; +} + function findFirstDocument(nodes: DocTreeNode[]): string | null { for (const node of nodes) { if (node.type === 'document') return node.path; @@ -90,6 +96,7 @@ export default function App() { const [moduleMessage, setModuleMessage] = useState(''); const [lastChannelReceipt, setLastChannelReceipt] = useState(''); const [worldEntered, setWorldEntered] = useState(false); + const [domainAccess, setDomainAccess] = useState({ blockers: [], runtimeReady: false, stage: 'checking' }); const storageMode = repositoryStatus?.remote ? 'server' : 'local'; const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机'; @@ -165,6 +172,19 @@ export default function App() { } }, []); + const refreshDomainAccess = useCallback(async () => { + const server = (window as any).hololake?.server; + if (!server?.domainAccess) { + setDomainAccess({ blockers: ['desktop_runtime_required'], runtimeReady: false, stage: 'login-required' }); + return; + } + try { + setDomainAccess(await server.domainAccess('DOM-FIFTH-0001')); + } catch { + setDomainAccess({ blockers: ['domain_access_probe_failed'], runtimeReady: false, stage: 'login-required' }); + } + }, []); + const refreshChannel = useCallback(async () => { try { const [channel, registry] = await Promise.all([api.getChannel(), api.getModules()]); @@ -179,8 +199,9 @@ export default function App() { refreshTree(); refreshRepositoryStatus(); refreshServerSession(); + refreshDomainAccess(); refreshChannel(); - }, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshChannel]); + }, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshDomainAccess, refreshChannel]); const changeModuleState = useCallback(async (moduleId: string, installed: boolean, mounted: boolean) => { setModuleBusy(true); @@ -358,7 +379,13 @@ export default function App() { <> {!worldEntered && ( { + access={domainAccess} + onEnterFifthRuntime={() => { + setActiveRoute('fifth'); + setActiveModule('knowledge'); + setWorldEntered(true); + }} + onEnterLocalWorkspace={() => { setActiveRoute('fifth'); setActiveModule('knowledge'); setWorldEntered(true); @@ -547,6 +574,7 @@ export default function App() { onApplied={() => { refreshRepositoryStatus(); refreshServerSession(); + refreshDomainAccess(); }} /> void; + access: { blockers: string[]; runtimeReady: boolean; stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready' }; + onEnterFifthRuntime: () => void; + onEnterLocalWorkspace: () => void; onOpenConnection: () => void; } @@ -16,7 +18,7 @@ const domains: Array<{ id: DomainId; number: string; name: string; summary: stri { id: 'fifth', number: '05', name: '第五域 · 光湖本源域', summary: '零点原核的工程本体 · 语言架构层' }, ]; -export function WorldEntry({ onEnterFifth, onOpenConnection }: Props) { +export function WorldEntry({ access, onEnterFifthRuntime, onEnterLocalWorkspace, onOpenConnection }: Props) { const [stage, setStage] = useState<'lighthouse' | 'directory' | 'fifth'>('lighthouse'); const [selectedDomain, setSelectedDomain] = useState(null); @@ -58,11 +60,11 @@ export function WorldEntry({ onEnterFifth, onOpenConnection }: Props) {
DOM-FIFTH-0001
零点原核的工程本体 · 语言架构层
本地终端节点 / 云端常驻节点
-
公开门厅可查看 · 进入后装载独立域运行体
+
{access.stage === 'runtime-ready' ? '节点、域清单、会话能力与连接回执已验证' : access.stage === 'identity-verified' ? '账户与节点已验证 · 尚缺域清单、会话能力和连接回执' : access.stage === 'checking' ? '正在核对真实接入证据' : '公开门厅可查看 · 尚未登录或接入节点'}
- - + +
diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index 84cd98c..8f13c41 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -18,6 +18,7 @@ import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; import { importKnowledgeFolder } from './folder-import.js'; +import { evaluateDomainAccess } from '../../guanghu-knowledge-base/server/domain-access.js'; // ─── 配置 ─── @@ -565,6 +566,25 @@ ipcMain.handle('server:session', async (_event, requestedNodeId?: string) => { return { authenticated: false, nodeId }; } }); +ipcMain.handle('server:domain-access', async (_event, domainId = 'DOM-FIFTH-0001') => { + const stored = applyStoredServerAuth(); + let accountVerified = false; + if (stored) { + try { + const { response } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token }); + accountVerified = response.ok; + } catch { + accountVerified = false; + } + } + // Forgejo identity is only the first proof. A signed manifest, scoped capability and + // matching online receipt must arrive from the lighthouse/domain before runtime entry. + return evaluateDomainAccess({ + accountVerified, + domainId: String(domainId), + nodeId: stored?.nodeId || defaultPersonalServerId() || '', + }); +}); ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => { const username = String(input.username || '').trim(); const password = String(input.password || ''); diff --git a/product-source/hololake-desktop/electron/preload.ts b/product-source/hololake-desktop/electron/preload.ts index 10d50c7..2880680 100644 --- a/product-source/hololake-desktop/electron/preload.ts +++ b/product-source/hololake-desktop/electron/preload.ts @@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld('hololake', { list: () => ipcRenderer.invoke('server:list'), connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId), domainRegistry: () => ipcRenderer.invoke('server:domain-registry'), + domainAccess: (domainId: string) => ipcRenderer.invoke('server:domain-access', domainId), session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId), login: (input: { nodeId: string; username: string; password: string }) => ipcRenderer.invoke('server:login', input),