diff --git a/product-source/guanghu-knowledge-base/public/fifth-domain-lighthouse.png b/product-source/guanghu-knowledge-base/public/fifth-domain-lighthouse.png new file mode 100644 index 0000000..a2c0a66 Binary files /dev/null and b/product-source/guanghu-knowledge-base/public/fifth-domain-lighthouse.png differ diff --git a/product-source/guanghu-knowledge-base/public/gh-aios-public-lighthouse.png b/product-source/guanghu-knowledge-base/public/gh-aios-public-lighthouse.png new file mode 100644 index 0000000..b782b76 Binary files /dev/null and b/product-source/guanghu-knowledge-base/public/gh-aios-public-lighthouse.png differ diff --git a/product-source/guanghu-knowledge-base/src/App.tsx b/product-source/guanghu-knowledge-base/src/App.tsx index 10cee34..4c50692 100644 --- a/product-source/guanghu-knowledge-base/src/App.tsx +++ b/product-source/guanghu-knowledge-base/src/App.tsx @@ -11,6 +11,7 @@ import { HumanSettings, HumanPreferences, loadHumanPreferences } from './compone import { DomainSurface } from './components/DomainSurface'; import { ModuleLibrarySheet } from './components/ModuleLibrarySheet'; import { cleanDisplayText } from './presentation'; +import { WorldEntry } from './components/WorldEntry'; type View = 'editor' | 'history'; type RouteId = 'fifth' | 'main' | 'sub' | 'zero' | 'zero-sense'; @@ -88,6 +89,7 @@ export default function App() { const [moduleBusy, setModuleBusy] = useState(false); const [moduleMessage, setModuleMessage] = useState(''); const [lastChannelReceipt, setLastChannelReceipt] = useState(''); + const [worldEntered, setWorldEntered] = useState(false); const storageMode = repositoryStatus?.remote ? 'server' : 'local'; const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机'; @@ -353,7 +355,21 @@ export default function App() { useEffect(() => { localStorage.setItem('hololake.layout.agent-width', String(Math.round(agentWidth))); }, [agentWidth]); return ( -
+ {!worldEntered && ( + { + setActiveRoute('fifth'); + setActiveModule('knowledge'); + setWorldEntered(true); + }} + onOpenConnection={() => { + setStorageSheetInitialMode('server'); + setStorageSheetOpen(true); + }} + /> + )} +
void changeModuleState(moduleId, installed, mounted)} onUndo={() => void undoModuleChange()} /> -
+
+ ); } diff --git a/product-source/guanghu-knowledge-base/src/components/WorldEntry.tsx b/product-source/guanghu-knowledge-base/src/components/WorldEntry.tsx new file mode 100644 index 0000000..44ec798 --- /dev/null +++ b/product-source/guanghu-knowledge-base/src/components/WorldEntry.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { BookOpen, Crosshair, DoorOpen, Landmark, Link2, LogIn, ShieldCheck } from 'lucide-react'; + +type DomainId = 'main' | 'sub' | 'zero' | 'zero-sense' | 'fifth'; + +interface Props { + onEnterFifth: () => void; + onOpenConnection: () => void; +} + +const domains: Array<{ id: DomainId; number: string; name: string; summary: string }> = [ + { id: 'main', number: '01', name: '光湖主域', summary: '公共产品事实、发布与公告入口' }, + { id: 'sub', number: '02', name: '光湖分域', summary: '行业入口与初始化频道目录' }, + { id: 'zero', number: '03', name: '光湖零域', summary: '实验、模块试装与质量验证入口' }, + { id: 'zero-sense', number: '04', name: '光湖零感域', summary: '治理、审批、回滚与审计入口' }, + { id: 'fifth', number: '05', name: '第五域 · 光湖本源域', summary: '零点原核的工程本体 · 语言架构层' }, +]; + +export function WorldEntry({ onEnterFifth, onOpenConnection }: Props) { + const [stage, setStage] = useState<'lighthouse' | 'directory' | 'fifth'>('lighthouse'); + const [selectedDomain, setSelectedDomain] = useState(null); + + const selectDomain = (id: DomainId) => { + setSelectedDomain(id); + if (id === 'fifth') setStage('fifth'); + }; + + if (stage === 'fifth') { + return ( +
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/product-source/guanghu-knowledge-base/src/main.tsx b/product-source/guanghu-knowledge-base/src/main.tsx index 00ee0d0..b273b63 100644 --- a/product-source/guanghu-knowledge-base/src/main.tsx +++ b/product-source/guanghu-knowledge-base/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import './styles/app.css'; import './styles/workspace-0.7.1.css'; +import './styles/world-entry.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/product-source/guanghu-knowledge-base/src/styles/world-entry.css b/product-source/guanghu-knowledge-base/src/styles/world-entry.css new file mode 100644 index 0000000..085f1e1 --- /dev/null +++ b/product-source/guanghu-knowledge-base/src/styles/world-entry.css @@ -0,0 +1,415 @@ +.world-entry { + position: fixed; + inset: 0; + z-index: 1000; + min-width: 860px; + min-height: 620px; + overflow: hidden; + color: #edf4ff; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + background: #06142b; +} + +.entry-backdrop, +.entry-vignette { + position: absolute; + inset: 0; + pointer-events: none; +} + +.public-lighthouse .entry-backdrop { + background: url('/gh-aios-public-lighthouse.png') center / cover no-repeat; +} + +.fifth-vestibule .entry-backdrop { + background: url('/fifth-domain-lighthouse.png') center / cover no-repeat; +} + +.public-lighthouse .entry-vignette { + background: + linear-gradient(180deg, rgba(1, 10, 27, .16) 0%, rgba(2, 13, 31, 0) 48%, rgba(1, 10, 24, .72) 100%), + radial-gradient(circle at 50% 32%, rgba(85, 131, 205, .08), rgba(0, 6, 19, .18) 64%, rgba(0, 5, 16, .42) 100%); +} + +.fifth-vestibule .entry-vignette { + background: linear-gradient(90deg, rgba(1, 5, 20, .28), rgba(1, 6, 25, .05) 65%, rgba(2, 4, 17, .2)); +} + +.entry-brand { + display: grid; + justify-items: center; + gap: 9px; + text-align: center; +} + +.entry-brand span { + font-size: clamp(24px, 2.7vw, 40px); + font-weight: 500; + letter-spacing: -.03em; +} + +.entry-brand strong { + color: #dbe9ff; + font-family: "Helvetica Neue", Inter, sans-serif; + font-size: clamp(58px, 7.3vw, 108px); + font-weight: 400; + line-height: .94; + letter-spacing: .05em; + text-shadow: 0 5px 24px rgba(73, 133, 220, .18); +} + +.entry-brand small { + color: rgba(232, 240, 255, .82); + font-size: clamp(14px, 1.35vw, 21px); + font-weight: 400; + letter-spacing: .25em; +} + +.public-entry-copy { + position: relative; + z-index: 1; + width: min(720px, 62vw); + margin: clamp(72px, 10vh, 120px) auto 0; + display: grid; + justify-items: center; + text-align: center; +} + +.public-divider { + width: 190px; + height: 1px; + margin: 30px 0 30px; + background: linear-gradient(90deg, transparent, rgba(202, 222, 250, .36), transparent); +} + +.public-entry-copy h1 { + margin: 0; + font-size: clamp(42px, 5vw, 68px); + font-weight: 500; + letter-spacing: .11em; +} + +.public-entry-copy > p { + margin: 15px 0 34px; + color: rgba(228, 237, 252, .72); + font-size: clamp(16px, 1.65vw, 24px); + letter-spacing: .22em; +} + +.public-entry-actions { + width: min(570px, 100%); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.public-entry-actions button, +.fifth-actions button { + min-height: 66px; + border: 1px solid rgba(172, 203, 245, .62); + border-radius: 12px; + background: rgba(13, 35, 72, .38); + color: #edf4ff; + font: inherit; + font-size: 19px; + font-weight: 500; + letter-spacing: .08em; + backdrop-filter: blur(14px); + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.public-entry-actions button:hover, +.fifth-actions button:hover, +.public-domain-strip button:hover { + transform: translateY(-2px); + border-color: rgba(224, 237, 255, .92); +} + +.public-entry-actions .public-primary { + border-color: rgba(231, 243, 255, .84); + background: linear-gradient(135deg, rgba(222, 237, 255, .92), rgba(131, 181, 243, .82)); + color: #09214a; + box-shadow: 0 12px 35px rgba(65, 128, 216, .18); +} + +.public-domain-strip { + position: absolute; + z-index: 1; + left: 8.5vw; + right: 8.5vw; + bottom: clamp(116px, 15vh, 168px); + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-bottom: 1px solid rgba(179, 207, 239, .22); +} + +.public-domain-strip button { + position: relative; + min-width: 0; + min-height: 86px; + padding: 8px 16px 26px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: rgba(230, 239, 252, .84); + font: inherit; + text-align: center; + transition: color .18s ease, border-color .18s ease, transform .18s ease; +} + +.public-domain-strip button + button::before { + content: '/'; + position: absolute; + left: -4px; + top: 22px; + color: rgba(158, 197, 244, .72); + font-size: 28px; + font-weight: 300; +} + +.public-domain-strip span, +.public-domain-strip strong, +.public-domain-strip small { + display: block; +} + +.public-domain-strip span { + margin-bottom: 8px; + color: rgba(154, 192, 239, .82); + font-size: 18px; +} + +.public-domain-strip strong { + overflow: hidden; + font-size: 16px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.public-domain-strip small { + margin-top: 8px; + color: rgba(208, 222, 242, .64); + font-size: 11px; + line-height: 1.5; +} + +.public-domain-strip button.selected, +.public-domain-strip button:focus-visible { + border-bottom-color: #b8d6ff; + color: #fff; + outline: none; +} + +.public-entry-footer { + position: absolute; + z-index: 1; + left: 50%; + bottom: 65px; + color: rgba(202, 219, 241, .6); + font-size: 14px; + letter-spacing: .16em; + transform: translateX(-50%); + white-space: nowrap; +} + +.public-lighthouse.is-directory .public-entry-copy { + margin-top: clamp(45px, 6vh, 75px); +} + +.public-lighthouse.is-directory .entry-brand strong { + font-size: clamp(46px, 5vw, 74px); +} + +.public-lighthouse.is-directory .public-divider { + margin: 20px 0; +} + +.public-lighthouse.is-directory .public-domain-strip { + bottom: clamp(92px, 12vh, 130px); +} + +.public-lighthouse.is-directory .public-domain-strip button { + min-height: 118px; +} + +.fifth-brand { + position: absolute; + z-index: 1; + top: 62px; + left: 50%; + transform: translateX(-50%); +} + +.fifth-brand span { font-size: 27px; } +.fifth-brand strong { + color: #ffe9b7; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(60px, 6.5vw, 92px); + text-shadow: 0 0 24px rgba(255, 201, 116, .16); +} +.fifth-brand small { color: rgba(255, 237, 204, .82); font-size: 14px; } + +.fifth-domain-rail { + position: absolute; + z-index: 1; + left: 4vw; + top: 15vh; + bottom: 23vh; + width: 238px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.fifth-domain-rail::before { + content: ''; + position: absolute; + left: 28px; + top: 24px; + bottom: 32px; + width: 1px; + background: linear-gradient(#9d83ce, #c6a063 78%, #ffe2a0); + box-shadow: 0 0 18px rgba(208, 168, 106, .22); +} + +.fifth-domain-rail button { + position: relative; + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + align-items: center; + gap: 14px; + min-height: 60px; + padding: 0; + border: 0; + background: transparent; + color: rgba(235, 231, 247, .86); + font: inherit; + text-align: left; +} + +.fifth-domain-rail button span { + position: relative; + z-index: 1; + width: 54px; + height: 54px; + display: grid; + place-items: center; + border: 1px solid rgba(197, 166, 227, .8); + border-radius: 50%; + background: rgba(11, 11, 41, .74); + font-size: 19px; +} + +.fifth-domain-rail button strong { + max-width: 130px; + font-size: 15px; + font-weight: 500; +} + +.fifth-domain-rail button:focus-visible { + outline: 1px solid #ffe1a1; + outline-offset: 5px; +} + +.fifth-domain-rail button.selected span { + border-color: #ffe1a1; + color: #ffe5ad; + font-size: 27px; + box-shadow: 0 0 0 11px rgba(255, 207, 126, .1), 0 0 30px rgba(255, 206, 124, .35); +} + +.fifth-domain-rail button.selected strong { + color: #ffe1a1; + font-size: 20px; +} + +.fifth-focus-panel { + position: absolute; + z-index: 1; + top: 27vh; + left: max(320px, 24vw); + width: min(820px, 56vw); + padding: 32px 42px 36px; + border: 1px solid rgba(174, 174, 217, .42); + border-radius: 18px; + background: rgba(6, 12, 43, .68); + box-shadow: 0 22px 80px rgba(1, 3, 18, .28); + backdrop-filter: blur(20px); +} + +.fifth-focus-panel > small { + color: #f3d49b; + font-size: 15px; + letter-spacing: .08em; +} + +.fifth-focus-panel h1 { + margin: 7px 0 13px; + color: #ffe1a8; + font-size: clamp(35px, 3.4vw, 52px); + font-weight: 600; + letter-spacing: .02em; +} + +.fifth-focus-panel dl { margin: 0; } +.fifth-focus-panel dl > div { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 18px; + align-items: center; + min-height: 63px; + border-top: 1px solid rgba(181, 181, 220, .22); +} + +.fifth-focus-panel dt { + display: flex; + align-items: center; + gap: 12px; + color: rgba(234, 230, 246, .72); +} +.fifth-focus-panel dt svg { width: 22px; height: 22px; stroke-width: 1.6; } +.fifth-focus-panel dd { margin: 0; color: rgba(244, 243, 251, .88); } + +.fifth-actions { + position: absolute; + top: calc(100% + 24px); + left: 50%; + width: min(650px, 86%); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + transform: translateX(-50%); +} + +.fifth-actions button { + min-height: 62px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 16px; +} +.fifth-actions button svg { width: 22px; height: 22px; stroke-width: 1.6; } +.fifth-actions .fifth-primary { + grid-column: 1 / -1; + width: min(510px, 82%); + min-height: 82px; + justify-self: center; + border-color: rgba(255, 238, 189, .92); + background: linear-gradient(135deg, rgba(240, 191, 111, .9), rgba(176, 129, 72, .8)); + color: #fff9ec; + font-size: 19px; + box-shadow: 0 0 0 3px rgba(255, 216, 144, .16), 0 0 26px rgba(255, 196, 103, .34); +} + +@media (max-height: 760px) { + .public-entry-copy { margin-top: 38px; } + .public-divider { margin: 17px 0; } + .public-entry-copy > p { margin-bottom: 22px; } + .public-domain-strip { bottom: 82px; } + .public-entry-footer { bottom: 28px; } + .fifth-brand { top: 24px; } + .fifth-focus-panel { top: 25vh; } + .fifth-actions { top: calc(100% + 14px); } +} diff --git a/product-source/hololake-desktop/design-qa.md b/product-source/hololake-desktop/design-qa.md new file mode 100644 index 0000000..4066966 --- /dev/null +++ b/product-source/hololake-desktop/design-qa.md @@ -0,0 +1,68 @@ +# HoloLake 0.8.0 · 公共灯塔与第五域门厅视觉验收 + +## 视觉事实源 + +- 公共灯塔选定稿:`/Users/bingshuolingdianyuanhe/.codex/generated_images/019fe458-4bb4-7530-aad0-f73f760ad018/exec-d674d5cb-760b-4415-9eb1-09e8dd84fec4.png` +- 第五域选定稿:`/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/ui-audit-20260809/selected-fifth-domain-visual-target-v2.png` +- 公共灯塔实现截图:`/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/ui-audit-20260809/implementation-public-lighthouse-final.png` +- 第五域实现截图:`/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/ui-audit-20260809/implementation-fifth-domain-final.png` +- 公共灯塔同屏对照:`/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/ui-audit-20260809/comparison-public-final.png` +- 第五域同屏对照:`/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/ui-audit-20260809/comparison-fifth-final.png` + +## 归一化条件 + +- CSS 视口:1488 × 1058。 +- deviceScaleFactor:1。 +- 选定稿像素:1487 × 1058;只在对照图中等比归一化为 1488 × 1058。 +- 实现截图像素:1488 × 1058。 +- 状态:未登录公共灯塔;第五域公开门厅;桌面端深色主题。 +- 浏览器渲染:HoloLake 0.8.0 本地生产构建预览。 + +## 最终对照结论 + +### 公共灯塔 + +- 字体与层级:HoloLake、GH-AIOS、公共灯塔、说明和操作按钮保持选定稿的单焦点层级;字重、字距和正文对比度均可读。 +- 布局与节奏:中央品牌、双按钮、五域横向门厅和底部节点说明与选定稿同构;没有额外卡片或多栏抢焦点。 +- 色彩与视觉 token:公共外壳保持克制深蓝,主按钮使用冷白蓝,灯塔只承担单一暖光焦点;未复制第五域的淡金专属主题。 +- 图像质量:使用独立的无文字高分辨率湖面灯塔背景,不以界面截图充当背景;1488 × 1058 下无拉伸、马赛克或明显压缩伪影。 +- 文案:软件全称、公共灯塔、五域名称、第五域正式名和“光湖零域”均正确。 + +### 第五域 · 光湖本源域 + +- 字体与层级:淡金 GH-AIOS、当前聚焦域、第五域名称、真实状态表和进入动作形成清楚的单一路径。 +- 布局与节奏:五域轨道、中央真实信息面板、主操作和两个次操作与选定稿的空间关系一致;核心内容没有散落或互相争抢。 +- 色彩与视觉 token:深蓝紫黑湖面、淡金暖白主光和蓝紫方向性光带符合第五域“湖面映星”语法,与公共主题保持同源但不相同。 +- 图像与图标:背景为独立生成的无界面资产;状态与操作使用 Lucide 正式图标组件,没有手绘 SVG、字符图标或占位符。 +- 文案:“光湖零域”纠正已经进入所有门厅;第五域稳定编号、零点原核定位、接入方式和独立运行体状态均为真实产品语义,不使用装饰假数据。 + +## 交互与运行检查 + +- “查看五域”可进入五域公开门厅。 +- “登录或接入节点”可打开现有真实节点/保存位置入口。 +- 第五域入口可进入第五域公开门厅。 +- “进入第五域”可打开现有知识、Agent 与频道工作区。 +- 14 项现有自动化测试全部通过。 +- HoloLake 0.8.0 生产构建通过。 +- 核心流程浏览器控制台错误:0。 + +## 对照迭代历史 + +1. v1:第五域信息面板偏窄,主次操作被压成同一行,和选定稿的动作层级不一致(P2)。 + - 修正:扩大信息面板、恢复一主两次的两层操作布局、调整轨道间距与字号。 + - 证据:`implementation-fifth-domain-v1.png` → `implementation-fifth-domain-v2.png`。 +2. v2:状态行与按钮缺少选定稿中的语义图标(P2)。 + - 修正:接入可树摇的 Lucide ESM 图标;生产包主脚本保持约 302 KB,没有因整库引入形成大包回退。 + - 证据:`implementation-fifth-domain-v2.png` → `implementation-fifth-domain-final.png`。 +3. 最终复验:公共与第五域选定稿和实现图均以相同视口同屏比较;没有剩余 P0、P1 或 P2 差异。 + +## 可接受的 P3 差异 + +- 字体由系统可用字体替代选定稿中的生成式展示字形,字面宽度存在轻微差别,但没有改变层级、换行或可读性。 +- 第五域背景光轨与选定稿不是逐像素复制,而是同一艺术方向的独立无文字生产资产;主体位置、色调与内容留白一致。 + +## Focused region comparison + +第五域中央面板和操作区在 1488 × 1058 原始像素下单独检查了字号、行高、分隔线、图标、按钮宽高与焦点状态;这些细节在全屏同屏对照中仍清晰可读,因此无需另行裁切放大。 + +final result: passed diff --git a/product-source/hololake-desktop/package-lock.json b/product-source/hololake-desktop/package-lock.json index d0b9359..99ce0a1 100644 --- a/product-source/hololake-desktop/package-lock.json +++ b/product-source/hololake-desktop/package-lock.json @@ -12,6 +12,7 @@ "diff": "^9.0.0", "express": "^5.1.0", "gray-matter": "^4.0.3", + "lucide-react": "^0.468.0", "marked": "^15.0.0", "simple-git": "^3.27.0" }, @@ -4747,6 +4748,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -5527,7 +5537,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/product-source/hololake-desktop/package.json b/product-source/hololake-desktop/package.json index 025df42..2e4538b 100644 --- a/product-source/hololake-desktop/package.json +++ b/product-source/hololake-desktop/package.json @@ -22,6 +22,7 @@ "diff": "^9.0.0", "express": "^5.1.0", "gray-matter": "^4.0.3", + "lucide-react": "^0.468.0", "marked": "^15.0.0", "simple-git": "^3.27.0" }, diff --git a/product-source/hololake-desktop/vite.config.ts b/product-source/hololake-desktop/vite.config.ts index b45c1c6..af90008 100644 --- a/product-source/hololake-desktop/vite.config.ts +++ b/product-source/hololake-desktop/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'react-dom': path.resolve(__dirname, 'node_modules/react-dom/index.js'), 'react': path.resolve(__dirname, 'node_modules/react/index.js'), 'marked': path.resolve(__dirname, 'node_modules/marked/lib/marked.esm.js'), + 'lucide-react': path.resolve(__dirname, 'node_modules/lucide-react/dist/esm/lucide-react.js'), }, }, root: path.resolve(__dirname, '../guanghu-knowledge-base'), diff --git a/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts b/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts new file mode 100644 index 0000000..e460949 --- /dev/null +++ b/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + INITIAL_DOMAIN_ACCESS_STATE, + acceptDomainConnectionReceipt, + acceptDomainSession, + assertVerifiedDomainManifest, + beginDomainAccess, + disconnectDomainRuntime, + enterDomainRuntime, + projectPublicDomainVestibule, + selectPublicDomain, + type DomainConnectionReceipt, + type DomainSessionCapability, + type VerifiedDomainManifest, +} from './domainRuntimeContract' + +const DIGEST = 'a'.repeat(64) +const NOW = 1_786_291_200_000 + +function manifest(): VerifiedDomainManifest { + return { + public: { + accessModes: ['LOCAL_TERMINAL_NODE', 'CLOUD_RESIDENT_NODE'], + displayName: '第五域 · 光湖本源域', + domainId: 'DOM-FIFTH-0001', + formalName: '光湖本源域', + publicStatus: 'PUBLIC_PREVIEW', + purpose: '零点原核的工程本体与语言架构源点域', + responsibleParty: '第五域主控', + themePreview: { + assetRef: 'theme-preview://origin-domain/lake-reflects-stars', + description: '湖面映星公开预览', + }, + }, + provenance: { + digest: DIGEST, + repositoryId: 'REPO-012', + signature: 'signature-envelope-reference', + signerId: 'SIGNER-FIFTH-DOMAIN-001', + sourceCommit: '6e26d1f35439759542efb317c3ddd7661ba27d58', + }, + runtime: { + backendVersion: 'not-published', + channelIds: ['CHANNEL-ZERO-CORE'], + forbiddenDataScopes: ['private-relationship-core'], + frontendVersion: 'not-published', + healthCheckRef: 'health://origin-domain/runtime', + moduleIds: ['MODULE-LANGUAGE-ARCHITECTURE'], + rollbackVersion: 'not-published', + routeRef: 'domain-route://origin-domain/runtime', + themePackageRef: 'theme://origin-domain/lake-reflects-stars', + }, + schema: 'gh-aios.domain-manifest/v1', + verification: { + manifestDigest: DIGEST, + state: 'verified', + verifiedAt: NOW, + }, + } +} + +function session(overrides: Partial = {}): DomainSessionCapability { + return { + capabilityId: 'CAP-001', + domainId: 'DOM-FIFTH-0001', + expiresAt: NOW + 60_000, + nodeId: 'LOCAL-NODE-001', + scopes: ['domain:enter'], + ...overrides, + } +} + +function receipt(overrides: Partial = {}): DomainConnectionReceipt { + return { + connectionId: 'CONNECTION-001', + domainId: 'DOM-FIFTH-0001', + manifestDigest: DIGEST, + nodeId: 'LOCAL-NODE-001', + receiptId: 'RECEIPT-001', + state: 'online', + ...overrides, + } +} + +describe('domain runtime contract', () => { + it('projects only public vestibule fields', () => { + const publicView = projectPublicDomainVestibule(manifest()) + const serialized = JSON.stringify(publicView) + + expect(publicView.displayName).toBe('第五域 · 光湖本源域') + expect(serialized).not.toContain('domain-route://') + expect(serialized).not.toContain('CHANNEL-ZERO-CORE') + expect(serialized).not.toContain('private-relationship-core') + expect(serialized).not.toContain('REPO-012') + }) + + it('rejects a manifest that has not passed the external signature verifier', () => { + const candidate = manifest() + candidate.verification.state = 'verified' + candidate.verification.manifestDigest = 'b'.repeat(64) + + expect(() => assertVerifiedDomainManifest(candidate)).toThrow('domain_manifest_digest_invalid') + }) + + it.each(['LOCAL_TERMINAL_NODE', 'CLOUD_RESIDENT_NODE'] as const)( + 'accepts %s as a user-owned access node type', + (nodeType) => { + const selected = selectPublicDomain(INITIAL_DOMAIN_ACCESS_STATE, manifest().public) + const access = beginDomainAccess(selected, manifest(), nodeType) + + expect(access).toMatchObject({ nodeType, stage: 'access' }) + }, + ) + + it('does not enter a runtime with only a selected vestibule or frontend login state', () => { + const selected = selectPublicDomain(INITIAL_DOMAIN_ACCESS_STATE, manifest().public) + const access = beginDomainAccess(selected, manifest(), 'LOCAL_TERMINAL_NODE') + + expect(() => enterDomainRuntime(access, NOW)).toThrow('domain_runtime_session_missing') + const withSession = acceptDomainSession(access, session(), NOW) + expect(() => enterDomainRuntime(withSession, NOW)).toThrow('domain_runtime_connection_receipt_missing') + }) + + it('enters only after a matching live session and connection receipt', () => { + const selected = selectPublicDomain(INITIAL_DOMAIN_ACCESS_STATE, manifest().public) + const access = beginDomainAccess(selected, manifest(), 'LOCAL_TERMINAL_NODE') + const withSession = acceptDomainSession(access, session(), NOW) + const connected = acceptDomainConnectionReceipt(withSession, receipt()) + const runtime = enterDomainRuntime(connected, NOW) + + expect(runtime).toMatchObject({ + mode: 'connected', + nodeType: 'LOCAL_TERMINAL_NODE', + stage: 'runtime', + }) + }) + + it('fails closed when the session or receipt belongs to another domain', () => { + const selected = selectPublicDomain(INITIAL_DOMAIN_ACCESS_STATE, manifest().public) + const access = beginDomainAccess(selected, manifest(), 'LOCAL_TERMINAL_NODE') + + expect(() => acceptDomainSession(access, session({ domainId: 'DOMAIN-OTHER' }), NOW)) + .toThrow('domain_session_domain_mismatch') + + const withSession = acceptDomainSession(access, session(), NOW) + expect(() => acceptDomainConnectionReceipt(withSession, receipt({ domainId: 'DOMAIN-OTHER' }))) + .toThrow('domain_connection_domain_mismatch') + }) + + it('drops execution authority and keeps an explicit read-only scene after disconnect', () => { + const selected = selectPublicDomain(INITIAL_DOMAIN_ACCESS_STATE, manifest().public) + const access = beginDomainAccess(selected, manifest(), 'LOCAL_TERMINAL_NODE') + const withSession = acceptDomainSession(access, session(), NOW) + const connected = acceptDomainConnectionReceipt(withSession, receipt()) + const runtime = enterDomainRuntime(connected, NOW) + + expect(disconnectDomainRuntime(runtime).mode).toBe('disconnected-readonly') + }) +}) diff --git a/product-source/hololake-platform/src/lib/domainRuntimeContract.ts b/product-source/hololake-platform/src/lib/domainRuntimeContract.ts new file mode 100644 index 0000000..4013f2e --- /dev/null +++ b/product-source/hololake-platform/src/lib/domainRuntimeContract.ts @@ -0,0 +1,269 @@ +export type DomainNodeType = 'LOCAL_TERMINAL_NODE' | 'CLOUD_RESIDENT_NODE' + +export type PublicDomainStatus = 'PUBLIC_PREVIEW' | 'RESTRICTED_PREVIEW' | 'UNAVAILABLE' + +export interface PublicThemePreview { + assetRef: string + description: string +} + +export interface PublicDomainVestibule { + accessModes: DomainNodeType[] + displayName: string + domainId: string + formalName: string + publicStatus: PublicDomainStatus + purpose: string + responsibleParty: string + themePreview: PublicThemePreview | null +} + +export interface DomainManifestProvenance { + digest: string + repositoryId: string + signature: string + signerId: string + sourceCommit: string +} + +export interface DomainRuntimeDescriptor { + backendVersion: string + channelIds: string[] + forbiddenDataScopes: string[] + frontendVersion: string + healthCheckRef: string + moduleIds: string[] + rollbackVersion: string + routeRef: string + themePackageRef: string +} + +export interface VerifiedDomainManifest { + public: PublicDomainVestibule + provenance: DomainManifestProvenance + runtime: DomainRuntimeDescriptor + schema: 'gh-aios.domain-manifest/v1' + verification: { + manifestDigest: string + state: 'verified' + verifiedAt: number + } +} + +export interface DomainSessionCapability { + capabilityId: string + domainId: string + expiresAt: number + nodeId: string + scopes: string[] +} + +export interface DomainConnectionReceipt { + connectionId: string + domainId: string + manifestDigest: string + nodeId: string + receiptId: string + state: 'online' +} + +export interface LighthouseState { + stage: 'lighthouse' +} + +export interface VestibuleState { + domain: PublicDomainVestibule + stage: 'vestibule' +} + +export interface AccessState { + manifest: VerifiedDomainManifest + nodeType: DomainNodeType + receipt: DomainConnectionReceipt | null + session: DomainSessionCapability | null + stage: 'access' +} + +export interface RuntimeState { + manifest: VerifiedDomainManifest + mode: 'connected' | 'disconnected-readonly' + nodeType: DomainNodeType + receipt: DomainConnectionReceipt + session: DomainSessionCapability + stage: 'runtime' +} + +export type DomainAccessState = LighthouseState | VestibuleState | AccessState | RuntimeState + +export class DomainContractError extends Error { + readonly code: string + + constructor(code: string) { + super(code) + this.code = code + this.name = 'DomainContractError' + } +} + +const SHA256 = /^[a-f0-9]{64}$/ +const SOURCE_COMMIT = /^[a-f0-9]{40,64}$/ +const REQUIRED_ENTER_SCOPE = 'domain:enter' + +function required(value: string, code: string): string { + if (!value.trim()) throw new DomainContractError(code) + return value +} + +function publicDomain(domain: PublicDomainVestibule): PublicDomainVestibule { + required(domain.domainId, 'domain_manifest_public_id_missing') + required(domain.formalName, 'domain_manifest_formal_name_missing') + required(domain.displayName, 'domain_manifest_display_name_missing') + required(domain.purpose, 'domain_manifest_public_purpose_missing') + required(domain.responsibleParty, 'domain_manifest_responsible_party_missing') + if (!domain.accessModes.length) { + throw new DomainContractError('domain_manifest_access_modes_missing') + } + if (domain.accessModes.some(mode => mode !== 'LOCAL_TERMINAL_NODE' && mode !== 'CLOUD_RESIDENT_NODE')) { + throw new DomainContractError('domain_manifest_access_mode_invalid') + } + if (domain.themePreview) { + required(domain.themePreview.assetRef, 'domain_manifest_theme_preview_ref_missing') + required(domain.themePreview.description, 'domain_manifest_theme_preview_description_missing') + } + return { + accessModes: [...domain.accessModes], + displayName: domain.displayName, + domainId: domain.domainId, + formalName: domain.formalName, + publicStatus: domain.publicStatus, + purpose: domain.purpose, + responsibleParty: domain.responsibleParty, + themePreview: domain.themePreview ? { ...domain.themePreview } : null, + } +} + +export function assertVerifiedDomainManifest( + manifest: VerifiedDomainManifest, +): VerifiedDomainManifest { + if (manifest.schema !== 'gh-aios.domain-manifest/v1') { + throw new DomainContractError('domain_manifest_schema_unsupported') + } + publicDomain(manifest.public) + if (manifest.verification.state !== 'verified' || !Number.isFinite(manifest.verification.verifiedAt)) { + throw new DomainContractError('domain_manifest_not_verified') + } + if (!SHA256.test(manifest.provenance.digest) + || manifest.verification.manifestDigest !== manifest.provenance.digest) { + throw new DomainContractError('domain_manifest_digest_invalid') + } + if (!SOURCE_COMMIT.test(manifest.provenance.sourceCommit)) { + throw new DomainContractError('domain_manifest_source_commit_invalid') + } + required(manifest.provenance.repositoryId, 'domain_manifest_repository_missing') + required(manifest.provenance.signerId, 'domain_manifest_signer_missing') + required(manifest.provenance.signature, 'domain_manifest_signature_missing') + required(manifest.runtime.routeRef, 'domain_manifest_route_missing') + required(manifest.runtime.themePackageRef, 'domain_manifest_theme_package_missing') + required(manifest.runtime.healthCheckRef, 'domain_manifest_health_check_missing') + required(manifest.runtime.rollbackVersion, 'domain_manifest_rollback_version_missing') + return manifest +} + +export function projectPublicDomainVestibule( + manifest: VerifiedDomainManifest, +): PublicDomainVestibule { + return publicDomain(assertVerifiedDomainManifest(manifest).public) +} + +export const INITIAL_DOMAIN_ACCESS_STATE: LighthouseState = { stage: 'lighthouse' } + +export function selectPublicDomain( + state: LighthouseState | VestibuleState, + domain: PublicDomainVestibule, +): VestibuleState { + void state + return { domain: publicDomain(domain), stage: 'vestibule' } +} + +export function beginDomainAccess( + state: VestibuleState, + manifest: VerifiedDomainManifest, + nodeType: DomainNodeType, +): AccessState { + const verified = assertVerifiedDomainManifest(manifest) + if (verified.public.domainId !== state.domain.domainId) { + throw new DomainContractError('domain_manifest_selected_domain_mismatch') + } + if (!verified.public.accessModes.includes(nodeType)) { + throw new DomainContractError('domain_node_type_not_allowed') + } + return { + manifest: verified, + nodeType, + receipt: null, + session: null, + stage: 'access', + } +} + +export function acceptDomainSession( + state: AccessState, + session: DomainSessionCapability, + now: number, +): AccessState { + if (session.domainId !== state.manifest.public.domainId) { + throw new DomainContractError('domain_session_domain_mismatch') + } + if (session.expiresAt <= now) { + throw new DomainContractError('domain_session_expired') + } + if (!session.scopes.includes(REQUIRED_ENTER_SCOPE)) { + throw new DomainContractError('domain_session_enter_scope_missing') + } + required(session.capabilityId, 'domain_session_capability_id_missing') + required(session.nodeId, 'domain_session_node_id_missing') + return { ...state, session: { ...session, scopes: [...session.scopes] } } +} + +export function acceptDomainConnectionReceipt( + state: AccessState, + receipt: DomainConnectionReceipt, +): AccessState { + if (!state.session) { + throw new DomainContractError('domain_connection_session_missing') + } + if (receipt.domainId !== state.manifest.public.domainId) { + throw new DomainContractError('domain_connection_domain_mismatch') + } + if (receipt.nodeId !== state.session.nodeId) { + throw new DomainContractError('domain_connection_node_mismatch') + } + if (receipt.manifestDigest !== state.manifest.provenance.digest) { + throw new DomainContractError('domain_connection_manifest_mismatch') + } + required(receipt.connectionId, 'domain_connection_id_missing') + required(receipt.receiptId, 'domain_connection_receipt_id_missing') + return { ...state, receipt: { ...receipt } } +} + +export function enterDomainRuntime(state: AccessState, now: number): RuntimeState { + if (!state.session) throw new DomainContractError('domain_runtime_session_missing') + if (!state.receipt) throw new DomainContractError('domain_runtime_connection_receipt_missing') + if (state.session.expiresAt <= now) throw new DomainContractError('domain_session_expired') + return { + manifest: state.manifest, + mode: 'connected', + nodeType: state.nodeType, + receipt: state.receipt, + session: state.session, + stage: 'runtime', + } +} + +export function disconnectDomainRuntime(state: RuntimeState): RuntimeState { + return { ...state, mode: 'disconnected-readonly' } +} + +export function returnToLighthouse(): LighthouseState { + return INITIAL_DOMAIN_ACCESS_STATE +}