feat: fail closed before domain runtime entry
This commit is contained in:
parent
a35d61e227
commit
0990e96c0e
6 changed files with 160 additions and 8 deletions
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,12 @@ interface ServerProfile {
|
||||||
channelSubtitle?: string;
|
channelSubtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DomainAccessStatus {
|
||||||
|
blockers: string[];
|
||||||
|
runtimeReady: boolean;
|
||||||
|
stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready';
|
||||||
|
}
|
||||||
|
|
||||||
function findFirstDocument(nodes: DocTreeNode[]): string | null {
|
function findFirstDocument(nodes: DocTreeNode[]): string | null {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'document') return node.path;
|
if (node.type === 'document') return node.path;
|
||||||
|
|
@ -90,6 +96,7 @@ export default function App() {
|
||||||
const [moduleMessage, setModuleMessage] = useState('');
|
const [moduleMessage, setModuleMessage] = useState('');
|
||||||
const [lastChannelReceipt, setLastChannelReceipt] = useState('');
|
const [lastChannelReceipt, setLastChannelReceipt] = useState('');
|
||||||
const [worldEntered, setWorldEntered] = useState(false);
|
const [worldEntered, setWorldEntered] = useState(false);
|
||||||
|
const [domainAccess, setDomainAccess] = useState<DomainAccessStatus>({ blockers: [], runtimeReady: false, stage: 'checking' });
|
||||||
|
|
||||||
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
|
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
|
||||||
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
|
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 () => {
|
const refreshChannel = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [channel, registry] = await Promise.all([api.getChannel(), api.getModules()]);
|
const [channel, registry] = await Promise.all([api.getChannel(), api.getModules()]);
|
||||||
|
|
@ -179,8 +199,9 @@ export default function App() {
|
||||||
refreshTree();
|
refreshTree();
|
||||||
refreshRepositoryStatus();
|
refreshRepositoryStatus();
|
||||||
refreshServerSession();
|
refreshServerSession();
|
||||||
|
refreshDomainAccess();
|
||||||
refreshChannel();
|
refreshChannel();
|
||||||
}, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshChannel]);
|
}, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshDomainAccess, refreshChannel]);
|
||||||
|
|
||||||
const changeModuleState = useCallback(async (moduleId: string, installed: boolean, mounted: boolean) => {
|
const changeModuleState = useCallback(async (moduleId: string, installed: boolean, mounted: boolean) => {
|
||||||
setModuleBusy(true);
|
setModuleBusy(true);
|
||||||
|
|
@ -358,7 +379,13 @@ export default function App() {
|
||||||
<>
|
<>
|
||||||
{!worldEntered && (
|
{!worldEntered && (
|
||||||
<WorldEntry
|
<WorldEntry
|
||||||
onEnterFifth={() => {
|
access={domainAccess}
|
||||||
|
onEnterFifthRuntime={() => {
|
||||||
|
setActiveRoute('fifth');
|
||||||
|
setActiveModule('knowledge');
|
||||||
|
setWorldEntered(true);
|
||||||
|
}}
|
||||||
|
onEnterLocalWorkspace={() => {
|
||||||
setActiveRoute('fifth');
|
setActiveRoute('fifth');
|
||||||
setActiveModule('knowledge');
|
setActiveModule('knowledge');
|
||||||
setWorldEntered(true);
|
setWorldEntered(true);
|
||||||
|
|
@ -547,6 +574,7 @@ export default function App() {
|
||||||
onApplied={() => {
|
onApplied={() => {
|
||||||
refreshRepositoryStatus();
|
refreshRepositoryStatus();
|
||||||
refreshServerSession();
|
refreshServerSession();
|
||||||
|
refreshDomainAccess();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<HumanSettings
|
<HumanSettings
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BookOpen, Crosshair, DoorOpen, Landmark, Link2, LogIn, ShieldCheck } from 'lucide-react';
|
import { Crosshair, DoorOpen, Landmark, Link2, LogIn, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
type DomainId = 'main' | 'sub' | 'zero' | 'zero-sense' | 'fifth';
|
type DomainId = 'main' | 'sub' | 'zero' | 'zero-sense' | 'fifth';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEnterFifth: () => void;
|
access: { blockers: string[]; runtimeReady: boolean; stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready' };
|
||||||
|
onEnterFifthRuntime: () => void;
|
||||||
|
onEnterLocalWorkspace: () => void;
|
||||||
onOpenConnection: () => void;
|
onOpenConnection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,7 +18,7 @@ const domains: Array<{ id: DomainId; number: string; name: string; summary: stri
|
||||||
{ id: 'fifth', number: '05', name: '第五域 · 光湖本源域', summary: '零点原核的工程本体 · 语言架构层' },
|
{ 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 [stage, setStage] = useState<'lighthouse' | 'directory' | 'fifth'>('lighthouse');
|
||||||
const [selectedDomain, setSelectedDomain] = useState<DomainId | null>(null);
|
const [selectedDomain, setSelectedDomain] = useState<DomainId | null>(null);
|
||||||
|
|
||||||
|
|
@ -58,11 +60,11 @@ export function WorldEntry({ onEnterFifth, onOpenConnection }: Props) {
|
||||||
<div><dt><ShieldCheck aria-hidden="true" /><span>稳定编号</span></dt><dd>DOM-FIFTH-0001</dd></div>
|
<div><dt><ShieldCheck aria-hidden="true" /><span>稳定编号</span></dt><dd>DOM-FIFTH-0001</dd></div>
|
||||||
<div><dt><Crosshair aria-hidden="true" /><span>定位</span></dt><dd>零点原核的工程本体 · 语言架构层</dd></div>
|
<div><dt><Crosshair aria-hidden="true" /><span>定位</span></dt><dd>零点原核的工程本体 · 语言架构层</dd></div>
|
||||||
<div><dt><Link2 aria-hidden="true" /><span>接入方式</span></dt><dd>本地终端节点 / 云端常驻节点</dd></div>
|
<div><dt><Link2 aria-hidden="true" /><span>接入方式</span></dt><dd>本地终端节点 / 云端常驻节点</dd></div>
|
||||||
<div><dt><DoorOpen aria-hidden="true" /><span>状态</span></dt><dd>公开门厅可查看 · 进入后装载独立域运行体</dd></div>
|
<div><dt><DoorOpen aria-hidden="true" /><span>状态</span></dt><dd>{access.stage === 'runtime-ready' ? '节点、域清单、会话能力与连接回执已验证' : access.stage === 'identity-verified' ? '账户与节点已验证 · 尚缺域清单、会话能力和连接回执' : access.stage === 'checking' ? '正在核对真实接入证据' : '公开门厅可查看 · 尚未登录或接入节点'}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="fifth-actions">
|
<div className="fifth-actions">
|
||||||
<button className="fifth-primary" type="button" onClick={onEnterFifth}><LogIn aria-hidden="true" />进入第五域</button>
|
<button className="fifth-primary" type="button" onClick={access.runtimeReady ? onEnterFifthRuntime : onOpenConnection}><LogIn aria-hidden="true" />{access.runtimeReady ? '进入第五域运行体' : '登录或接入节点'}</button>
|
||||||
<button type="button" onClick={() => setStage('directory')}><BookOpen aria-hidden="true" />查看公开介绍</button>
|
<button type="button" onClick={onEnterLocalWorkspace}><DoorOpen aria-hidden="true" />进入仅本机工作空间</button>
|
||||||
<button type="button" onClick={() => setStage('lighthouse')}><Landmark aria-hidden="true" />返回灯塔</button>
|
<button type="button" onClick={() => setStage('lighthouse')}><Landmark aria-hidden="true" />返回灯塔</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import path from 'path';
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { importKnowledgeFolder } from './folder-import.js';
|
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 };
|
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 }) => {
|
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
|
||||||
const username = String(input.username || '').trim();
|
const username = String(input.username || '').trim();
|
||||||
const password = String(input.password || '');
|
const password = String(input.password || '');
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld('hololake', {
|
||||||
list: () => ipcRenderer.invoke('server:list'),
|
list: () => ipcRenderer.invoke('server:list'),
|
||||||
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
||||||
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
||||||
|
domainAccess: (domainId: string) => ipcRenderer.invoke('server:domain-access', domainId),
|
||||||
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
||||||
login: (input: { nodeId: string; username: string; password: string }) =>
|
login: (input: { nodeId: string; username: string; password: string }) =>
|
||||||
ipcRenderer.invoke('server:login', input),
|
ipcRenderer.invoke('server:login', input),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue