From 27cd1645dac1676d10ddc0d031ca5aa402fad005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 10 Aug 2026 07:28:11 +0800 Subject: [PATCH] Add node registration claim transport --- .../server/domain-access-orchestrator.test.ts | 11 +- .../server/domain-access-orchestrator.ts | 17 +- .../server/node-registration-client.test.ts | 153 +++++++++++++++ .../server/node-registration-client.ts | 177 ++++++++++++++++++ .../server/node-registration.ts | 9 + .../hololake-desktop/electron/main.ts | 11 +- ...AKE-NODE-REGISTRATION-CONTRACT-20260810.md | 14 ++ .../hololake-platform/architecture/README.md | 2 +- 8 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 product-source/guanghu-knowledge-base/server/node-registration-client.test.ts create mode 100644 product-source/guanghu-knowledge-base/server/node-registration-client.ts diff --git a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts index 90afb47..6157958 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts @@ -191,11 +191,15 @@ test('projects both trust-source states without asking for claims or handoff bef }); test('a signed node registration without a handoff remains identity-only', async () => { + let observedEndpointId = ''; const orchestrator = new DomainAccessOrchestrator( signerLoader(), registrationLoader(), loggedInIdentity, - validRegistrationSource, + { async read(request) { + observedEndpointId = request.endpoint.endpointId; + return validRegistrationClaim(); + } }, { async read() { return null; } }, ); @@ -203,6 +207,7 @@ test('a signed node registration without a handoff remains identity-only', async assert.equal(status.stage, 'identity-verified'); assert.equal(status.runtimeReady, false); + assert.equal(observedEndpointId, 'GH-NODE-REG-001'); assert.equal(status.blockers.includes('verified_node_registration_missing'), false); assert.ok(status.blockers.includes('verified_domain_manifest_missing')); }); @@ -246,13 +251,14 @@ test('current registries, signed node claim and verified handoff project runtime }); test('source failures are reduced to safe status without raw details', async () => { + let claimReads = 0; const unavailableSigner = new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret signer detail'); } }); const unavailableRegistration = new NodeRegistrationSnapshotLoader({ async fetchJson() { throw new Error('secret registration detail'); } }); const orchestrator = new DomainAccessOrchestrator( unavailableSigner, unavailableRegistration, loggedInIdentity, - { async read() { throw new Error('secret claim detail'); } }, + { async read() { claimReads += 1; throw new Error('secret claim detail'); } }, { async read() { throw new Error('secret handoff detail'); } }, ); @@ -262,6 +268,7 @@ test('source failures are reduced to safe status without raw details', async () assert.equal(status.runtimeReady, false); assert.equal(status.nodeRegistrationSource.status, 'UNAVAILABLE'); assert.equal(status.trustSource.status, 'UNAVAILABLE'); + assert.equal(claimReads, 0); assert.equal(JSON.stringify(status).includes('secret'), false); }); diff --git a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts index 323ab56..1790bad 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts @@ -18,6 +18,7 @@ import { NodeRegistrationSnapshotLoader, type NodeRegistrationSnapshotReceipt, } from './node-registration-snapshot.js'; +import type { NodeRegistrationClaimSource } from './node-registration-client.js'; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; @@ -31,19 +32,6 @@ export interface DomainIdentitySource { read(domainId: string, nodeType: DomainNodeType): Promise; } -export interface NodeRegistrationClaimRequest { - accountId: string; - domainId: string; - endpointId: string; - endpointUrl: string; - nodeId: string; - nodeType: DomainNodeType; -} - -export interface NodeRegistrationClaimSource { - read(request: Readonly): Promise; -} - export interface DomainRuntimeHandoffCandidate { handoff: unknown; signerLookup: { @@ -113,8 +101,7 @@ export class DomainAccessOrchestrator { const claim = await this.nodeRegistrations.read(Object.freeze({ accountId: identity.accountId, domainId, - endpointId: endpoint.endpointId, - endpointUrl: endpoint.url, + endpoint, nodeId: identity.nodeId, nodeType, })); diff --git a/product-source/guanghu-knowledge-base/server/node-registration-client.test.ts b/product-source/guanghu-knowledge-base/server/node-registration-client.test.ts new file mode 100644 index 0000000..9d89d92 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration-client.test.ts @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { HttpNodeRegistrationClaimSource } from './node-registration-client.js'; +import { + parseNodeRegistrationEndpointRegistry, + resolveNodeRegistrationEndpoint, +} from './node-registration.js'; + +const NOW = 1_786_291_200_000; +const COMMIT = 'a'.repeat(40); +const { publicKey } = generateKeyPairSync('ed25519'); + +function registeredEndpoint() { + const registry = parseNodeRegistrationEndpointRegistry({ + endpoints: [{ + algorithm: 'Ed25519', + domainIds: ['DOM-FIFTH-0001'], + endpointId: 'GH-NODE-REG-001', + nodeTypes: ['local-terminal'], + publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(), + signerId: 'GH-NODE-REG-SIGNER-001', + status: 'ACTIVE', + url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims', + }], + registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + schema: 'gh-aios.node-registration-endpoints/v1', + state: 'CURRENT', + version: '1.0.0', + }, { + repositoryId: 'REPO-012', + sourceCommit: COMMIT, + sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`, + }); + const endpoint = resolveNodeRegistrationEndpoint(registry, { + domainId: 'DOM-FIFTH-0001', + nodeType: 'local-terminal', + }); + assert.ok(endpoint); + return endpoint; +} + +function lookup(endpoint = registeredEndpoint()) { + return { + accountId: 'bingshuo', + domainId: 'DOM-FIFTH-0001', + endpoint, + nodeId: 'LOCAL-001', + nodeType: 'local-terminal' as const, + }; +} + +function responseBody(status: 'ISSUED' | 'NOT_FOUND' = 'ISSUED', overrides: Record = {}) { + return { + claim: status === 'ISSUED' ? { signed: 'claim' } : null, + requestId: 'NODE-REQ-001', + schema: 'gh-aios.node-registration-claim-response/v1', + status, + ...overrides, + }; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + ...init, + headers: { 'Content-Type': 'application/json', ...init.headers }, + }); +} + +test('posts an exact credentialless claim lookup only to the registered endpoint', async () => { + let observedUrl = ''; + let observedInit: RequestInit | undefined; + const source = new HttpNodeRegistrationClaimSource(async (url, init) => { + observedUrl = url; + observedInit = init; + return jsonResponse(responseBody()); + }, () => NOW, () => 'NODE-REQ-001'); + + const claim = await source.read(lookup()); + + assert.deepEqual(claim, { signed: 'claim' }); + assert.equal(observedUrl, 'https://guanghulab.com/api/ai/v1/node-registrations/claims'); + assert.equal(observedInit?.method, 'POST'); + assert.equal(observedInit?.credentials, 'omit'); + assert.equal(observedInit?.redirect, 'manual'); + assert.equal(observedInit?.cache, 'no-store'); + assert.equal(observedInit?.referrerPolicy, 'no-referrer'); + assert.deepEqual(JSON.parse(String(observedInit?.body)), { + accountId: 'bingshuo', + domainId: 'DOM-FIFTH-0001', + endpointId: 'GH-NODE-REG-001', + issuedAt: NOW, + nodeId: 'LOCAL-001', + nodeType: 'local-terminal', + requestId: 'NODE-REQ-001', + schema: 'gh-aios.node-registration-claim-request/v1', + }); + const headers = observedInit?.headers as Record; + assert.equal(headers.Authorization, undefined); + assert.equal(headers.Cookie, undefined); +}); + +test('returns null only for an exact NOT_FOUND response', async () => { + const source = new HttpNodeRegistrationClaimSource( + async () => jsonResponse(responseBody('NOT_FOUND')), + () => NOW, + () => 'NODE-REQ-001', + ); + assert.equal(await source.read(lookup()), null); +}); + +test('rejects caller-created endpoints before any network request', async () => { + let called = false; + const source = new HttpNodeRegistrationClaimSource(async () => { + called = true; + return jsonResponse(responseBody()); + }, () => NOW, () => 'NODE-REQ-001'); + const forged = { ...registeredEndpoint(), url: 'https://guanghulab.com/api/ai/v1/other' }; + + await assert.rejects(source.read(lookup(forged)), /transport_invalid/); + assert.equal(called, false); +}); + +test('rejects redirects, non-json responses, oversized bodies and mismatched envelopes', async () => { + const cases: Array<() => Response> = [ + () => new Response(null, { status: 302, headers: { Location: 'https://guanghulab.com/api/ai/v1/other' } }), + () => new Response('{}', { status: 200, headers: { 'Content-Type': 'text/plain' } }), + () => new Response('x', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Length': '65537' } }), + () => new Response(`"${'x'.repeat(65_536)}"`, { status: 200, headers: { 'Content-Type': 'application/json' } }), + () => jsonResponse(responseBody('ISSUED', { requestId: 'NODE-REQ-OTHER' })), + () => jsonResponse({ ...responseBody(), extra: true }), + () => jsonResponse(responseBody('NOT_FOUND', { claim: {} })), + ]; + for (const makeResponse of cases) { + const source = new HttpNodeRegistrationClaimSource( + async () => makeResponse(), + () => NOW, + () => 'NODE-REQ-001', + ); + await assert.rejects(source.read(lookup()), /transport_invalid/); + } +}); + +test('reduces network errors to one non-sensitive transport failure', async () => { + const source = new HttpNodeRegistrationClaimSource(async () => { + throw new Error('secret endpoint detail'); + }, () => NOW, () => 'NODE-REQ-001'); + await assert.rejects(source.read(lookup()), error => { + assert.equal((error as Error).message, 'node_registration_claim_transport_invalid'); + return true; + }); +}); diff --git a/product-source/guanghu-knowledge-base/server/node-registration-client.ts b/product-source/guanghu-knowledge-base/server/node-registration-client.ts new file mode 100644 index 0000000..b1240b6 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration-client.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto'; +import type { DomainNodeType } from './domain-access.js'; +import { + assertRegisteredNodeRegistrationEndpoint, + type NodeRegistrationEndpoint, +} from './node-registration.js'; + +const REQUEST_SCHEMA = 'gh-aios.node-registration-claim-request/v1' as const; +const RESPONSE_SCHEMA = 'gh-aios.node-registration-claim-response/v1' as const; +const MAX_RESPONSE_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 5_000; +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; +const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; +const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; + +export interface NodeRegistrationClaimLookup { + accountId: string; + domainId: string; + endpoint: NodeRegistrationEndpoint; + nodeId: string; + nodeType: DomainNodeType; +} + +export interface NodeRegistrationClaimRequestBody { + accountId: string; + domainId: string; + endpointId: string; + issuedAt: number; + nodeId: string; + nodeType: DomainNodeType; + requestId: string; + schema: typeof REQUEST_SCHEMA; +} + +export interface NodeRegistrationClaimSource { + read(lookup: Readonly): Promise; +} + +type FetchImplementation = (input: string, init: RequestInit) => Promise; + +function invalidTransport(): never { + throw new Error('node_registration_claim_transport_invalid'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(record: Record, keys: string[]): boolean { + const expected = [...keys].sort(); + const actual = Object.keys(record).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function buildRequest( + lookup: Readonly, + now: number, + requestId: string, +): Readonly { + let endpoint: NodeRegistrationEndpoint; + try { + endpoint = assertRegisteredNodeRegistrationEndpoint(lookup.endpoint); + } catch { + invalidTransport(); + } + if (endpoint.status !== 'ACTIVE' + || !endpoint.domainIds.includes(lookup.domainId) + || !endpoint.nodeTypes.includes(lookup.nodeType) + || !ACCOUNT_ID_PATTERN.test(lookup.accountId) + || !IDENTIFIER_PATTERN.test(lookup.domainId) + || !IDENTIFIER_PATTERN.test(lookup.nodeId) + || !IDENTIFIER_PATTERN.test(requestId) + || !Number.isSafeInteger(now)) invalidTransport(); + return Object.freeze({ + accountId: lookup.accountId, + domainId: lookup.domainId, + endpointId: endpoint.endpointId, + issuedAt: now, + nodeId: lookup.nodeId, + nodeType: lookup.nodeType, + requestId, + schema: REQUEST_SCHEMA, + }); +} + +async function readLimitedBody(response: Response): Promise { + if (!response.body) invalidTransport(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) invalidTransport(); + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseResponse(input: unknown, request: NodeRegistrationClaimRequestBody): unknown | null { + if (!isRecord(input) + || !hasExactKeys(input, ['claim', 'requestId', 'schema', 'status']) + || input.schema !== RESPONSE_SCHEMA + || input.requestId !== request.requestId + || (input.status !== 'ISSUED' && input.status !== 'NOT_FOUND')) invalidTransport(); + if (input.status === 'NOT_FOUND') { + if (input.claim !== null) invalidTransport(); + return null; + } + if (!isRecord(input.claim)) invalidTransport(); + return input.claim; +} + +export class HttpNodeRegistrationClaimSource implements NodeRegistrationClaimSource { + constructor( + private readonly fetchImpl: FetchImplementation = fetch, + private readonly now: () => number = Date.now, + private readonly requestId: () => string = () => `NODE-REQ-${randomUUID().toUpperCase()}`, + ) {} + + async read(lookup: Readonly): Promise { + const issuedAt = this.now(); + const request = buildRequest(lookup, issuedAt, this.requestId()); + let response: Response; + try { + response = await this.fetchImpl(lookup.endpoint.url, { + body: JSON.stringify(request), + cache: 'no-store', + credentials: 'omit', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + redirect: 'manual', + referrerPolicy: 'no-referrer', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + invalidTransport(); + } + if (response.status !== 200 + || response.redirected + || !response.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { + invalidTransport(); + } + const contentLength = response.headers.get('content-length'); + if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) { + invalidTransport(); + } + let decoded: string; + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(await readLimitedBody(response)); + } catch { + invalidTransport(); + } + let parsed: unknown; + try { + parsed = JSON.parse(decoded); + } catch { + invalidTransport(); + } + if (Math.abs(this.now() - issuedAt) > MAX_CLOCK_SKEW_MS) invalidTransport(); + return parseResponse(parsed, request); + } +} diff --git a/product-source/guanghu-knowledge-base/server/node-registration.ts b/product-source/guanghu-knowledge-base/server/node-registration.ts index 97f5754..0cbb002 100644 --- a/product-source/guanghu-knowledge-base/server/node-registration.ts +++ b/product-source/guanghu-knowledge-base/server/node-registration.ts @@ -204,6 +204,15 @@ export function resolveNodeRegistrationEndpoint( return endpoint; } +export function assertRegisteredNodeRegistrationEndpoint( + endpoint: NodeRegistrationEndpoint, +): NodeRegistrationEndpoint { + if (!registeredEndpoints.has(endpoint)) { + throw new Error('node_registration_endpoint_unregistered'); + } + return endpoint; +} + export function nodeRegistrationClaimSigningBytes(payload: NodeRegistrationClaimPayload): Buffer { return Buffer.from(JSON.stringify({ accountId: payload.accountId, diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index 22216ce..d132b17 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -21,6 +21,7 @@ import { importKnowledgeFolder } from './folder-import.js'; import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js'; import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-snapshot.js'; import { NodeRegistrationSnapshotLoader } from '../../guanghu-knowledge-base/server/node-registration-snapshot.js'; +import { HttpNodeRegistrationClaimSource } from '../../guanghu-knowledge-base/server/node-registration-client.js'; // ─── 配置 ─── @@ -336,6 +337,8 @@ const nodeRegistrationSnapshots = new NodeRegistrationSnapshotLoader({ }, }); +const nodeRegistrationClaims = new HttpNodeRegistrationClaimSource(); + const domainAccessOrchestrator = new DomainAccessOrchestrator( trustedSignerSnapshots, nodeRegistrationSnapshots, @@ -357,13 +360,7 @@ const domainAccessOrchestrator = new DomainAccessOrchestrator( } }, }, - { - async read() { - // Current REPO-012 does not yet register a node-registration endpoint. - // Keep this transport absent until that public registry and service exist. - return null; - }, - }, + nodeRegistrationClaims, { async read() { // 当前灯塔尚未发布签名运行体交接端点。保持关闭,不从账号身份推导运行体权限。 diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md index e14bd4b..976dc9c 100644 --- a/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md +++ b/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md @@ -44,3 +44,17 @@ - 当前桌面没有可调用的登记声明端点; - 当前没有生产节点登记签发公钥、登记声明或节点密钥挑战回执; - 因而 Forgejo 账号验证以后仍停留在“需要节点登记”,这是正确的失败关闭结果。 + +## 登记声明读取传输 · 阶段 002 + +登记声明读取不是“创建节点登记”,也不携带账号密码、访问令牌、验证码或节点私钥。客户端 +只在当前锚点注册表为 `CURRENT` 且解析出唯一活跃端点后,向该端点发送固定结构的无凭据 +`POST`:账号标识、域、节点编号、节点类型、端点编号、一次性请求编号与签发时间。 + +传输必须同时满足:禁止重定向、5 秒超时、`application/json`、响应不超过 64 KiB、响应 +请求编号与原请求一致。响应只允许 `ISSUED + claim` 或 `NOT_FOUND + null`;前者仍必须经过 +阶段 001 的 Ed25519 声明验证,后者不能升级任何权限。HTTP 错误、未知字段、超限、绑定不 +一致及解析失败全部关闭为“未验证节点登记”,并且不触发运行体交接。 + +当前公共锚点仍未登记端点,所以该传输在生产运行中不可达,也不会发出请求;实现存在不等于 +端点服务、节点登记或域运行体已经存在。 diff --git a/product-source/hololake-platform/architecture/README.md b/product-source/hololake-platform/architecture/README.md index f4f6066..f7979dc 100644 --- a/product-source/hololake-platform/architecture/README.md +++ b/product-source/hololake-platform/architecture/README.md @@ -8,7 +8,7 @@ - [`HOLOLAKE-INTENT-REASONING-MAP-20260809.md`](HOLOLAKE-INTENT-REASONING-MAP-20260809.md):当前产品意图、纠正、思维推理与因果链。 - [`HOLOLAKE-SYSTEM-ARCHITECTURE-20260809.md`](HOLOLAKE-SYSTEM-ARCHITECTURE-20260809.md):`HLP-CURRENT-ARCH-001` 当前完整系统蓝图,覆盖 GH-AIOS 灯塔、五域独立入口、光湖本源域、节点登录与光湖桥。 - [`HOLOLAKE-DESKTOP-0.8.0-CURRENT-CAPABILITY-GAP-20260809.md`](HOLOLAKE-DESKTOP-0.8.0-CURRENT-CAPABILITY-GAP-20260809.md):桌面 0.8.0 已观察能力与最终规划的逐项差距;同时登记线上产品源码仍为 0.4.6 的分叉事实。 -- [`HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md`](HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md):账号验证与节点登记分离后的签名声明、端点注册表、失败关闭和当前生产缺口验收基线。 +- [`HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md`](HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md):账号验证与节点登记分离后的签名声明、端点注册表、无凭据声明读取传输、失败关闭和当前生产缺口验收基线。 - [`HOLOLAKE-LANGUAGE-PERSONA-OS.md`](HOLOLAKE-LANGUAGE-PERSONA-OS.md):HoloLake 作为 AI 语言人格驱动操作系统的当前正式产品定位、操作系统映射、原生应用边界与实现缺口。 - [`HOLOLAKE-INTENT-REASONING-MAP-20260807.md`](HOLOLAKE-INTENT-REASONING-MAP-20260807.md):2026-08-07 历史意图层,继续保存形成过程;不再作为当前默认入口。