From 8807b99d98714ba65ccb47153b332fe864832f21 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 06:28:47 +0800 Subject: [PATCH] Bind domain access to registered node type --- .../server/domain-access-orchestrator.test.ts | 38 ++++++++++---- .../server/domain-access-orchestrator.ts | 22 +++++--- .../server/domain-access.test.ts | 50 ++++++++++++++----- .../server/domain-access.ts | 25 ++++++++-- .../domain-runtime-handoff-source.test.ts | 13 +++-- .../server/domain-runtime-handoff-source.ts | 7 ++- .../guanghu-knowledge-base/src/App.tsx | 19 ++++--- .../src/domain-connection.test.ts | 3 +- .../src/domain-connection.ts | 4 +- .../src/domain-entry-state.test.ts | 12 +++-- .../src/domain-entry-state.ts | 4 ++ .../hololake-desktop/electron/main.ts | 17 ++++--- .../hololake-desktop/electron/preload.ts | 3 +- .../src/lib/domainRuntimeContract.test.ts | 6 +++ .../src/lib/domainRuntimeContract.ts | 8 +++ 15 files changed, 168 insertions(+), 63 deletions(-) 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 4bd0f2f..21d35cd 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 @@ -8,6 +8,7 @@ import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js'; const NOW = 1_786_291_200_000; const DOMAIN_ID = 'DOM-FIFTH-0001'; const NODE_ID = 'LOCAL-001'; +const NODE_TYPE = 'local-terminal' as const; const COMMIT = 'a'.repeat(40); const ANCHOR_URL = 'https://guanghulab.com/api/ai/v1/anchor'; const REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/trusted-domain-manifest-signers.json`; @@ -85,6 +86,7 @@ function validHandoff() { domainId: DOMAIN_ID, manifestDigest: digest, nodeId: NODE_ID, + nodeType: NODE_TYPE, receiptId: 'RECEIPT-001', state: 'online', }, @@ -98,6 +100,7 @@ function validHandoff() { domainId: DOMAIN_ID, expiresAt: NOW + 60_000, nodeId: NODE_ID, + nodeType: NODE_TYPE, scopes: ['domain:enter'], }, }; @@ -107,11 +110,11 @@ test('projects public trust health without asking for a handoff before login', a let handoffReads = 0; const orchestrator = new DomainAccessOrchestrator( loader(), - { async read() { return { accountVerified: false, nodeId: '' }; } }, + { async read() { return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; } }, { async read() { handoffReads += 1; return null; } }, ); - const status = await orchestrator.domainAccess(DOMAIN_ID, NOW); + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); assert.equal(handoffReads, 0); assert.equal(status.stage, 'login-required'); @@ -128,21 +131,36 @@ test('projects public trust health without asking for a handoff before login', a test('an authenticated node without a verified handoff remains identity-only', async () => { const orchestrator = new DomainAccessOrchestrator( loader(), - { async read() { return { accountVerified: true, nodeId: NODE_ID }; } }, + { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, { async read() { return null; } }, ); - const status = await orchestrator.domainAccess(DOMAIN_ID, NOW); + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); assert.equal(status.stage, 'identity-verified'); assert.equal(status.runtimeReady, false); assert.ok(status.blockers.includes('verified_domain_manifest_missing')); }); +test('a code-channel account without node registration never contacts a domain handoff route', async () => { + let handoffReads = 0; + const orchestrator = new DomainAccessOrchestrator( + loader(), + { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: false }; } }, + { async read() { handoffReads += 1; return validHandoff() as never; } }, + ); + + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); + + assert.equal(handoffReads, 0); + assert.equal(status.stage, 'login-required'); + assert.ok(status.blockers.includes('verified_node_registration_missing')); +}); + test('a loaded signer snapshot and verified handoff project runtime-ready', async () => { const orchestrator = new DomainAccessOrchestrator( loader(() => registry([activeSigner()])), - { async read() { return { accountVerified: true, nodeId: NODE_ID }; } }, + { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, { async read() { return { handoff: validHandoff(), @@ -151,7 +169,7 @@ test('a loaded signer snapshot and verified handoff project runtime-ready', asyn } }, ); - const status = await orchestrator.domainAccess(DOMAIN_ID, NOW); + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); assert.equal(status.stage, 'runtime-ready'); assert.equal(status.runtimeReady, true); @@ -162,11 +180,11 @@ test('a loaded signer snapshot and verified handoff project runtime-ready', asyn test('source and handoff failures are reduced to safe status without raw details', async () => { const orchestrator = new DomainAccessOrchestrator( new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret source detail'); } }), - { async read() { return { accountVerified: true, nodeId: NODE_ID }; } }, + { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, { async read() { throw new Error('secret handoff detail'); } }, ); - const status = await orchestrator.domainAccess(DOMAIN_ID, NOW); + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); assert.equal(status.stage, 'identity-verified'); assert.equal(status.runtimeReady, false); @@ -182,7 +200,7 @@ test('a last-known-good signer can project access while preserving degraded heal currentRegistry = { ...registry([activeSigner()]), version: 'invalid' }; const orchestrator = new DomainAccessOrchestrator( signerLoader, - { async read() { return { accountVerified: true, nodeId: NODE_ID }; } }, + { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, { async read() { return { handoff: validHandoff(), @@ -191,7 +209,7 @@ test('a last-known-good signer can project access while preserving degraded heal } }, ); - const status = await orchestrator.domainAccess(DOMAIN_ID, NOW); + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); assert.equal(status.runtimeReady, true); assert.equal(status.trustSource.status, 'DEGRADED_LAST_KNOWN_GOOD'); 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 a616750..9f45d9b 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts @@ -2,6 +2,7 @@ import { evaluateDomainAccess, verifyDomainAccessHandoffFromSnapshot, type DomainAccessEvidence, + type DomainNodeType, type DomainAccessStatus, } from './domain-access.js'; import { @@ -14,10 +15,11 @@ const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; export interface DomainIdentityProjection { accountVerified: boolean; nodeId: string; + nodeRegistrationVerified: boolean; } export interface DomainIdentitySource { - read(domainId: string): Promise; + read(domainId: string, nodeType: DomainNodeType): Promise; } export interface DomainRuntimeHandoffCandidate { @@ -29,7 +31,7 @@ export interface DomainRuntimeHandoffCandidate { } export interface DomainRuntimeHandoffSource { - read(domainId: string, nodeId: string): Promise; + read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise; } export interface DomainAccessProjection { @@ -37,6 +39,7 @@ export interface DomainAccessProjection { domainId: string; localWorkspaceAllowed: true; nodeId: string; + nodeType: DomainNodeType; runtimeReady: boolean; stage: DomainAccessStatus['stage']; trustSource: Readonly; @@ -44,7 +47,7 @@ export interface DomainAccessProjection { function safeIdentity(input: unknown): DomainIdentityProjection { if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return { accountVerified: false, nodeId: '' }; + return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; } const candidate = input as Record; const nodeId = typeof candidate.nodeId === 'string' && IDENTIFIER_PATTERN.test(candidate.nodeId) @@ -53,6 +56,7 @@ function safeIdentity(input: unknown): DomainIdentityProjection { return { accountVerified: candidate.accountVerified === true && Boolean(nodeId), nodeId, + nodeRegistrationVerified: candidate.nodeRegistrationVerified === true && Boolean(nodeId), }; } @@ -63,22 +67,25 @@ export class DomainAccessOrchestrator { private readonly handoffs: DomainRuntimeHandoffSource, ) {} - async domainAccess(domainId: string, now = Date.now()): Promise> { + async domainAccess(domainId: string, nodeType: DomainNodeType, now = Date.now()): Promise> { if (!IDENTIFIER_PATTERN.test(domainId)) throw new Error('domain_access_domain_invalid'); + if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') throw new Error('domain_access_node_type_invalid'); const [snapshot, identity] = await Promise.all([ this.signerSnapshots.refresh(), - this.identities.read(domainId).then(safeIdentity, () => safeIdentity(null)), + this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)), ]); const evidence: DomainAccessEvidence = { accountVerified: identity.accountVerified, domainId, nodeId: identity.nodeId, + nodeRegistrationVerified: identity.nodeRegistrationVerified, + nodeType, }; - if (identity.accountVerified) { + if (identity.accountVerified && identity.nodeRegistrationVerified) { let candidate: DomainRuntimeHandoffCandidate | null = null; try { - candidate = await this.handoffs.read(domainId, identity.nodeId); + candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType); } catch { candidate = null; } @@ -88,6 +95,7 @@ export class DomainAccessOrchestrator { candidate.handoff, domainId, identity.nodeId, + nodeType, candidate.signerLookup, snapshot, now, diff --git a/product-source/guanghu-knowledge-base/server/domain-access.test.ts b/product-source/guanghu-knowledge-base/server/domain-access.test.ts index 4b2520e..43879c5 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.test.ts @@ -16,6 +16,7 @@ import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js'; const NOW = 1_786_291_200_000; const COMMIT = 'b'.repeat(40); +const NODE_TYPE = 'local-terminal' as const; const { privateKey, publicKey } = generateKeyPairSync('ed25519'); function trustedSignerFromRegistry(overrides: Record = {}): TrustedManifestSigner { const parsed = parseTrustedManifestSignerRegistry({ @@ -62,6 +63,7 @@ const validHandoff = { domainId: 'DOM-FIFTH-0001', manifestDigest: DIGEST, nodeId: 'LOCAL-001', + nodeType: NODE_TYPE, receiptId: 'RECEIPT-001', state: 'online', }, @@ -75,6 +77,7 @@ const validHandoff = { domainId: 'DOM-FIFTH-0001', expiresAt: NOW + 60_000, nodeId: 'LOCAL-001', + nodeType: NODE_TYPE, scopes: ['domain:enter'], }, } as const; @@ -82,6 +85,7 @@ const verifiedHandoff = parseDomainAccessHandoff( validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, trustedSigner, NOW, ); @@ -143,14 +147,21 @@ function snapshotLoader(readRegistry: () => unknown) { } test('a local workspace remains available without claiming domain runtime access', () => { - const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '' }, NOW); + const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, 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); +test('a code-channel account cannot substitute for verified node registration', () => { + const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW); + assert.equal(status.stage, 'login-required'); + assert.equal(status.blockers.includes('account_identity_missing'), false); + assert.ok(status.blockers.includes('verified_node_registration_missing')); +}); + +test('a separately verified node is still not a domain runtime session', () => { + const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: true, nodeType: NODE_TYPE }, NOW); assert.equal(status.stage, 'identity-verified'); assert.deepEqual(status.blockers, [ 'verified_domain_manifest_missing', @@ -160,11 +171,13 @@ test('a verified Forgejo account is not a domain runtime session', () => { }); test('runtime access requires matching manifest, scoped capability and online receipt', () => { - const handoff = parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW); + const handoff = parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW); const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'LOCAL-001', + nodeRegistrationVerified: true, + nodeType: NODE_TYPE, ...handoff, }, NOW); assert.equal(status.runtimeReady, true); @@ -178,6 +191,7 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot' validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' }, snapshot, NOW, @@ -189,6 +203,8 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot' accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'LOCAL-001', + nodeRegistrationVerified: true, + nodeType: NODE_TYPE, ...verified.handoff, }, NOW).runtimeReady, true); }); @@ -199,6 +215,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff', validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' }, empty, NOW, @@ -209,6 +226,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff', validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' }, { ...loaded }, NOW, @@ -217,6 +235,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff', validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, { repositoryId: 'REPO-OTHER', signerId: 'GH-LIGHTHOUSE-001' }, loaded, NOW, @@ -234,6 +253,7 @@ test('a clearly degraded last-known-good snapshot remains usable without claimin validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' }, degraded, NOW, @@ -246,11 +266,13 @@ test('a clearly degraded last-known-good snapshot remains usable without claimin test('mismatched or expired evidence fails closed', () => { const status = evaluateDomainAccess({ accountVerified: true, - connectionReceipt: { connectionId: 'CONN-001', domainId: 'DOMAIN-OTHER', manifestDigest: DIGEST, nodeId: 'LOCAL-001', receiptId: 'RECEIPT-001', state: 'online' }, + connectionReceipt: { connectionId: 'CONN-001', domainId: 'DOMAIN-OTHER', manifestDigest: DIGEST, nodeId: 'LOCAL-001', nodeType: NODE_TYPE, receiptId: 'RECEIPT-001', state: 'online' }, domainId: 'DOM-FIFTH-0001', manifest: verifiedHandoff.manifest, nodeId: 'LOCAL-001', - sessionCapability: { capabilityId: 'CAP-001', domainId: 'DOM-FIFTH-0001', expiresAt: NOW, nodeId: 'LOCAL-001', scopes: ['domain:enter'] }, + nodeRegistrationVerified: true, + nodeType: NODE_TYPE, + sessionCapability: { capabilityId: 'CAP-001', domainId: 'DOM-FIFTH-0001', expiresAt: NOW, nodeId: 'LOCAL-001', nodeType: NODE_TYPE, scopes: ['domain:enter'] }, }, NOW); assert.equal(status.runtimeReady, false); assert.ok(status.blockers.includes('scoped_session_capability_missing')); @@ -264,7 +286,7 @@ test('the input cannot supply its own verifier receipt', () => { ...validHandoff.manifest, verifierReceipt: { state: 'verified' }, }, - }, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW), /domain_access_handoff_invalid/); + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); }); test('unknown fields and malformed digests fail closed', () => { @@ -272,22 +294,23 @@ test('unknown fields and malformed digests fail closed', () => { ...validHandoff, manifest: { ...validHandoff.manifest, digest: 'not-a-digest' }, trusted: true, - }, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW), /domain_access_handoff_invalid/); + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); }); test('the handoff is bound to the requested domain, node and manifest digest', () => { assert.throws(() => parseDomainAccessHandoff({ ...validHandoff, connectionReceipt: { ...validHandoff.connectionReceipt, manifestDigest: 'c'.repeat(64) }, - }, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW), /domain_access_handoff_invalid/); - assert.throws(() => parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'OTHER-NODE', trustedSigner, NOW), /domain_access_handoff_invalid/); + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); + assert.throws(() => parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'OTHER-NODE', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); + assert.throws(() => parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', 'cloud-resident', trustedSigner, NOW), /domain_access_handoff_invalid/); }); test('manifest tampering and signatures from another key fail closed', () => { assert.throws(() => parseDomainAccessHandoff({ ...validHandoff, manifest: { ...validHandoff.manifest, sourceCommit: 'c'.repeat(40) }, - }, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW), /domain_access_handoff_invalid/); + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); const other = generateKeyPairSync('ed25519'); assert.throws(() => parseDomainAccessHandoff({ @@ -296,7 +319,7 @@ test('manifest tampering and signatures from another key fail closed', () => { ...validHandoff.manifest, signature: sign(null, signedBytes, other.privateKey).toString('base64'), }, - }, 'DOM-FIFTH-0001', 'LOCAL-001', trustedSigner, NOW), /domain_access_handoff_invalid/); + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); }); test('trusted signer identity and repository are external inputs, not payload authority', () => { @@ -304,6 +327,7 @@ test('trusted signer identity and repository are external inputs, not payload au validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', + NODE_TYPE, trustedSignerFromRegistry({ repositoryId: 'REPO-OTHER' }), NOW, ), /domain_access_handoff_invalid/); @@ -319,6 +343,8 @@ test('copying verified-looking fields cannot bypass the in-process verifier boun verifierReceipt: { ...verifiedHandoff.manifest.verifierReceipt }, }, nodeId: 'LOCAL-001', + nodeRegistrationVerified: true, + nodeType: NODE_TYPE, sessionCapability: verifiedHandoff.sessionCapability, }, NOW); assert.equal(status.runtimeReady, false); diff --git a/product-source/guanghu-knowledge-base/server/domain-access.ts b/product-source/guanghu-knowledge-base/server/domain-access.ts index fca432b..4917654 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.ts @@ -16,6 +16,8 @@ const SHA256_PATTERN = /^[a-f0-9]{64}$/; const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; const cryptographicallyVerifiedManifests = new WeakSet(); +export type DomainNodeType = 'local-terminal' | 'cloud-resident'; + export interface DomainManifestEvidence { digest: string; domainId: string; @@ -46,6 +48,7 @@ export interface DomainSessionCapability { domainId: string; expiresAt: number; nodeId: string; + nodeType: DomainNodeType; scopes: string[]; } @@ -54,6 +57,7 @@ export interface DomainConnectionReceipt { domainId: string; manifestDigest: string; nodeId: string; + nodeType: DomainNodeType; receiptId: string; state: 'online'; } @@ -75,6 +79,8 @@ export interface DomainAccessEvidence { domainId: string; manifest?: DomainManifestEvidence; nodeId: string; + nodeRegistrationVerified: boolean; + nodeType: DomainNodeType; sessionCapability?: DomainSessionCapability; } @@ -83,6 +89,7 @@ export interface DomainAccessStatus { domainId: string; localWorkspaceAllowed: true; nodeId: string; + nodeType: DomainNodeType; runtimeReady: boolean; stage: 'login-required' | 'identity-verified' | 'runtime-ready'; } @@ -123,6 +130,7 @@ export function parseDomainAccessHandoff( input: unknown, expectedDomainId: string, expectedNodeId: string, + expectedNodeType: DomainNodeType, trustedSigner: TrustedManifestSigner, now = Date.now(), ): DomainAccessHandoff { @@ -132,8 +140,8 @@ export function parseDomainAccessHandoff( const capability = input.sessionCapability; const connection = input.connectionReceipt; if (!isRecord(manifest) || !hasExactKeys(manifest, ['digest', 'domainId', 'repositoryId', 'schema', 'signature', 'signerId', 'sourceCommit'])) invalidHandoff(); - if (!isRecord(capability) || !hasExactKeys(capability, ['capabilityId', 'domainId', 'expiresAt', 'nodeId', 'scopes'])) invalidHandoff(); - if (!isRecord(connection) || !hasExactKeys(connection, ['connectionId', 'domainId', 'manifestDigest', 'nodeId', 'receiptId', 'state'])) invalidHandoff(); + if (!isRecord(capability) || !hasExactKeys(capability, ['capabilityId', 'domainId', 'expiresAt', 'nodeId', 'nodeType', 'scopes'])) invalidHandoff(); + if (!isRecord(connection) || !hasExactKeys(connection, ['connectionId', 'domainId', 'manifestDigest', 'nodeId', 'nodeType', 'receiptId', 'state'])) invalidHandoff(); const digest = manifest.digest; if (manifest.schema !== MANIFEST_SCHEMA @@ -167,6 +175,7 @@ export function parseDomainAccessHandoff( if (!isIdentifier(capability.capabilityId) || capability.domainId !== expectedDomainId || capability.nodeId !== expectedNodeId + || capability.nodeType !== expectedNodeType || typeof capability.expiresAt !== 'number' || !Number.isSafeInteger(capability.expiresAt) || capability.expiresAt <= now || !Array.isArray(capability.scopes) || capability.scopes.some((scope) => !isIdentifier(scope)) @@ -176,6 +185,7 @@ export function parseDomainAccessHandoff( || !isIdentifier(connection.receiptId) || connection.domainId !== expectedDomainId || connection.nodeId !== expectedNodeId + || connection.nodeType !== expectedNodeType || connection.manifestDigest !== digest || connection.state !== 'online') invalidHandoff(); @@ -201,6 +211,7 @@ export function verifyDomainAccessHandoffFromSnapshot( input: unknown, expectedDomainId: string, expectedNodeId: string, + expectedNodeType: DomainNodeType, signerLookup: Omit, snapshot: TrustedSignerSnapshotResult, now = Date.now(), @@ -221,7 +232,7 @@ export function verifyDomainAccessHandoffFromSnapshot( }); if (!trustedSigner) invalidHandoff(); return Object.freeze({ - handoff: parseDomainAccessHandoff(input, expectedDomainId, expectedNodeId, trustedSigner, now), + handoff: parseDomainAccessHandoff(input, expectedDomainId, expectedNodeId, expectedNodeType, trustedSigner, now), trustSource: source, }); } catch { @@ -231,7 +242,8 @@ export function verifyDomainAccessHandoffFromSnapshot( 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.accountVerified) blockers.push('account_identity_missing'); + if (!evidence.nodeRegistrationVerified || !evidence.nodeId) blockers.push('verified_node_registration_missing'); const manifest = evidence.manifest; const verifier = manifest?.verifierReceipt; if (!manifest @@ -249,6 +261,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. || !capability.capabilityId || capability.domainId !== evidence.domainId || capability.nodeId !== evidence.nodeId + || capability.nodeType !== evidence.nodeType || capability.expiresAt <= now || !capability.scopes.includes('domain:enter')) { blockers.push('scoped_session_capability_missing'); @@ -260,6 +273,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. || receipt.state !== 'online' || receipt.domainId !== evidence.domainId || receipt.nodeId !== evidence.nodeId + || receipt.nodeType !== evidence.nodeType || receipt.manifestDigest !== evidence.manifest?.digest) { blockers.push('matching_connection_receipt_missing'); } @@ -269,7 +283,8 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. domainId: evidence.domainId, localWorkspaceAllowed: true, nodeId: evidence.nodeId, + nodeType: evidence.nodeType, runtimeReady, - stage: runtimeReady ? 'runtime-ready' : evidence.accountVerified && Boolean(evidence.nodeId) ? 'identity-verified' : 'login-required', + stage: runtimeReady ? 'runtime-ready' : evidence.accountVerified && evidence.nodeRegistrationVerified && Boolean(evidence.nodeId) ? 'identity-verified' : 'login-required', }; } diff --git a/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.test.ts b/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.test.ts index a34b7bc..bbf631e 100644 --- a/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.test.ts @@ -4,6 +4,7 @@ import { StrictDomainRuntimeHandoffSource } from './domain-runtime-handoff-sourc const DOMAIN_ID = 'DOM-FIFTH-0001'; const NODE_ID = 'JD-FD-PRIMARY'; +const NODE_TYPE = 'cloud-resident' as const; function response(overrides: Record = {}) { return { @@ -26,9 +27,9 @@ test('adapts one exact endpoint-neutral response without interpreting signed evi }, }); - const candidate = await source.read(DOMAIN_ID, NODE_ID); + const candidate = await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE); - assert.deepEqual(requests, [{ domainId: DOMAIN_ID, nodeId: NODE_ID }]); + assert.deepEqual(requests, [{ domainId: DOMAIN_ID, nodeId: NODE_ID, nodeType: NODE_TYPE }]); assert.deepEqual(candidate, { handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } }, signerLookup: { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' }, @@ -52,7 +53,7 @@ test('rejects malformed, extended and self-authorizing response envelopes', asyn }); for (const _sample of samples) { - assert.equal(await source.read(DOMAIN_ID, NODE_ID), null); + assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), null); } }); @@ -65,8 +66,10 @@ test('fails closed on invalid request identity and transport errors without leak }, }); - assert.equal(await source.read('bad domain', NODE_ID), null); + assert.equal(await source.read('bad domain', NODE_ID, NODE_TYPE), null); assert.equal(requests, 0); - assert.equal(await source.read(DOMAIN_ID, NODE_ID), null); + assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), null); + assert.equal(requests, 1); + assert.equal(await source.read(DOMAIN_ID, NODE_ID, 'invalid' as never), null); assert.equal(requests, 1); }); diff --git a/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.ts b/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.ts index c4f0976..30370c2 100644 --- a/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.ts +++ b/product-source/guanghu-knowledge-base/server/domain-runtime-handoff-source.ts @@ -2,12 +2,14 @@ import type { DomainRuntimeHandoffCandidate, DomainRuntimeHandoffSource, } from './domain-access-orchestrator.js'; +import type { DomainNodeType } from './domain-access.js'; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; export interface DomainRuntimeHandoffRequest { domainId: string; nodeId: string; + nodeType: DomainNodeType; } export interface DomainRuntimeHandoffTransport { @@ -49,10 +51,11 @@ function parseResponse(input: unknown): Readonly export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource { constructor(private readonly transport: DomainRuntimeHandoffTransport) {} - async read(domainId: string, nodeId: string): Promise | null> { + async read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise | null> { if (!IDENTIFIER_PATTERN.test(domainId) || !IDENTIFIER_PATTERN.test(nodeId)) return null; + if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') return null; try { - return parseResponse(await this.transport.request(Object.freeze({ domainId, nodeId }))); + return parseResponse(await this.transport.request(Object.freeze({ domainId, nodeId, nodeType }))); } catch { return null; } diff --git a/product-source/guanghu-knowledge-base/src/App.tsx b/product-source/guanghu-knowledge-base/src/App.tsx index 6315b6f..98d0718 100644 --- a/product-source/guanghu-knowledge-base/src/App.tsx +++ b/product-source/guanghu-knowledge-base/src/App.tsx @@ -47,6 +47,7 @@ interface DomainAccessStatus { blockers: string[]; domainId?: string; nodeId?: string; + nodeType?: DomainNodeType; runtimeReady: boolean; stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready'; trustSource?: { @@ -179,18 +180,18 @@ export default function App() { } }, []); - const refreshDomainAccess = useCallback(async (domainId = 'DOM-FIFTH-0001') => { + const refreshDomainAccess = useCallback(async (domainId = 'DOM-FIFTH-0001', nodeType: DomainNodeType = 'local-terminal') => { const request = ++domainAccessRequest.current; const server = (window as any).hololake?.server; if (!server?.domainAccess) { - if (request === domainAccessRequest.current) setDomainAccess({ blockers: ['desktop_runtime_required'], domainId, runtimeReady: false, stage: 'login-required' }); + if (request === domainAccessRequest.current) setDomainAccess({ blockers: ['desktop_runtime_required'], domainId, nodeType, runtimeReady: false, stage: 'login-required' }); return; } try { - const result = await server.domainAccess(domainId); + const result = await server.domainAccess(domainId, nodeType); if (request === domainAccessRequest.current) setDomainAccess(result); } catch { - if (request === domainAccessRequest.current) setDomainAccess({ blockers: ['domain_access_probe_failed'], domainId, runtimeReady: false, stage: 'login-required' }); + if (request === domainAccessRequest.current) setDomainAccess({ blockers: ['domain_access_probe_failed'], domainId, nodeType, runtimeReady: false, stage: 'login-required' }); } }, []); @@ -200,13 +201,17 @@ export default function App() { setDomainConnectionOpen(true); if (target) { setDomainAccess({ blockers: [], domainId: target.domain.stableDomainId, runtimeReady: false, stage: 'checking' }); - void refreshDomainAccess(target.domain.stableDomainId); + void refreshDomainAccess(target.domain.stableDomainId, target.nodeType); } }, [refreshDomainAccess]); const selectDomainNodeType = useCallback((nodeType: DomainNodeType) => { - setDomainEntryTarget(current => current ? createDomainEntryTarget(current.domain.routeId, nodeType) : null); - }, []); + if (!domainEntryTarget) return; + const target = createDomainEntryTarget(domainEntryTarget.domain.routeId, nodeType); + setDomainEntryTarget(target); + setDomainAccess({ blockers: [], domainId: target.domain.stableDomainId, nodeType, runtimeReady: false, stage: 'checking' }); + void refreshDomainAccess(target.domain.stableDomainId, nodeType); + }, [domainEntryTarget, refreshDomainAccess]); const refreshChannel = useCallback(async () => { try { diff --git a/product-source/guanghu-knowledge-base/src/domain-connection.test.ts b/product-source/guanghu-knowledge-base/src/domain-connection.test.ts index 56c0a68..e123ec1 100644 --- a/product-source/guanghu-knowledge-base/src/domain-connection.test.ts +++ b/product-source/guanghu-knowledge-base/src/domain-connection.test.ts @@ -5,7 +5,8 @@ import { projectDomainConnectionSteps, projectDomainTrustSource } from './domain test('code-channel account absence cannot be projected as a domain runtime login', () => { const steps = projectDomainConnectionSteps({ blockers: [ - 'account_node_identity_missing', + 'account_identity_missing', + 'verified_node_registration_missing', 'verified_domain_manifest_missing', 'scoped_session_capability_missing', 'matching_connection_receipt_missing', diff --git a/product-source/guanghu-knowledge-base/src/domain-connection.ts b/product-source/guanghu-knowledge-base/src/domain-connection.ts index 1c48558..780a44b 100644 --- a/product-source/guanghu-knowledge-base/src/domain-connection.ts +++ b/product-source/guanghu-knowledge-base/src/domain-connection.ts @@ -4,6 +4,7 @@ export interface DomainAccessProjection { blockers: string[]; domainId?: string; nodeId?: string; + nodeType?: 'local-terminal' | 'cloud-resident'; runtimeReady: boolean; stage: DomainAccessStage; trustSource?: { @@ -23,7 +24,8 @@ export interface DomainConnectionStep { } const blockerToStep: Record = { - account_node_identity_missing: 'identity', + account_identity_missing: 'identity', + verified_node_registration_missing: 'identity', verified_domain_manifest_missing: 'manifest', scoped_session_capability_missing: 'capability', matching_connection_receipt_missing: 'receipt', diff --git a/product-source/guanghu-knowledge-base/src/domain-entry-state.test.ts b/product-source/guanghu-knowledge-base/src/domain-entry-state.test.ts index 2901e47..6138c39 100644 --- a/product-source/guanghu-knowledge-base/src/domain-entry-state.test.ts +++ b/product-source/guanghu-knowledge-base/src/domain-entry-state.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { canEnterSelectedDomainRuntime, createDomainEntryTarget, projectDomainRuntimeBoundary } from './domain-entry-state.js'; -const ready = (domainId: string) => ({ blockers: [], domainId, runtimeReady: true, stage: 'runtime-ready' as const }); +const ready = (domainId: string, nodeType: 'local-terminal' | 'cloud-resident' = 'local-terminal') => ({ blockers: [], domainId, nodeType, runtimeReady: true, stage: 'runtime-ready' as const }); test('a generic login request does not silently target the Fifth Domain', () => { assert.equal(canEnterSelectedDomainRuntime(null, ready('DOM-FIFTH-0001')), false); @@ -11,8 +11,8 @@ test('a generic login request does not silently target the Fifth Domain', () => test('the selected stable domain id must match the returned access evidence', () => { const fifth = createDomainEntryTarget('fifth', 'cloud-resident'); - assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOMAIN-MAIN')), false); - assert.match(projectDomainRuntimeBoundary(fifth, ready('DOMAIN-MAIN')), /另一个域/); + assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOMAIN-MAIN', 'cloud-resident')), false); + assert.match(projectDomainRuntimeBoundary(fifth, ready('DOMAIN-MAIN', 'cloud-resident')), /另一个域/); }); test('enterprise vestibules cannot reuse the Fifth Domain renderer', () => { @@ -25,6 +25,8 @@ test('enterprise vestibules cannot reuse the Fifth Domain renderer', () => { test('the Fifth Domain opens only with matching runtime-ready evidence', () => { const fifth = createDomainEntryTarget('fifth', 'cloud-resident'); - assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOM-FIFTH-0001')), true); - assert.match(projectDomainRuntimeBoundary(fifth, ready('DOM-FIFTH-0001')), /已经匹配/); + assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOM-FIFTH-0001', 'cloud-resident')), true); + assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOM-FIFTH-0001', 'local-terminal')), false); + assert.match(projectDomainRuntimeBoundary(fifth, ready('DOM-FIFTH-0001', 'local-terminal')), /另一种节点类型/); + assert.match(projectDomainRuntimeBoundary(fifth, ready('DOM-FIFTH-0001', 'cloud-resident')), /已经匹配/); }); diff --git a/product-source/guanghu-knowledge-base/src/domain-entry-state.ts b/product-source/guanghu-knowledge-base/src/domain-entry-state.ts index 70c6495..bfddb1a 100644 --- a/product-source/guanghu-knowledge-base/src/domain-entry-state.ts +++ b/product-source/guanghu-knowledge-base/src/domain-entry-state.ts @@ -17,6 +17,7 @@ export function createDomainEntryTarget(routeId: DomainRouteId, nodeType: Domain export function canEnterSelectedDomainRuntime(target: DomainEntryTarget | null, access: DomainAccessProjection): boolean { if (!target || !access.runtimeReady) return false; if (access.domainId !== target.domain.stableDomainId) return false; + if (access.nodeType !== target.nodeType) return false; // The present desktop bundle contains only the Fifth Domain renderer. The four // enterprise domains must provide their own signed runtime package and endpoint. return target.domain.routeId === 'fifth'; @@ -27,6 +28,9 @@ export function projectDomainRuntimeBoundary(target: DomainEntryTarget | null, a if (access.domainId && access.domainId !== target.domain.stableDomainId) { return '回读证据属于另一个域;当前入口保持关闭。'; } + if (access.nodeType && access.nodeType !== target.nodeType) { + return '回读证据属于另一种节点类型;当前入口保持关闭并重新核验。'; + } if (target.domain.routeId !== 'fifth') { return `${target.domain.displayName}的独立运行端点与主题包尚未登记;当前只能查看公开门厅。`; } diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index 67a31d2..f88cfba 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -329,14 +329,14 @@ const domainAccessOrchestrator = new DomainAccessOrchestrator( { async read() { const stored = applyStoredServerAuth(); - if (!stored) { - return { accountVerified: false, nodeId: defaultPersonalServerId() || '' }; - } + if (!stored) return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; try { const { response } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token }); - return { accountVerified: response.ok, nodeId: stored.nodeId }; + // Forgejo proves only the account part. A dedicated node-registration + // verifier must independently supply nodeRegistrationVerified. + return { accountVerified: response.ok, nodeId: stored.nodeId, nodeRegistrationVerified: false }; } catch { - return { accountVerified: false, nodeId: stored.nodeId }; + return { accountVerified: false, nodeId: stored.nodeId, nodeRegistrationVerified: false }; } }, }, @@ -602,8 +602,11 @@ ipcMain.handle('server:session', async (_event, requestedNodeId?: string) => { return { authenticated: false, nodeId }; } }); -ipcMain.handle('server:domain-access', async (_event, domainId = 'DOM-FIFTH-0001') => { - return domainAccessOrchestrator.domainAccess(String(domainId)); +ipcMain.handle('server:domain-access', async (_event, input: { domainId?: unknown; nodeType?: unknown } = {}) => { + const domainId = String(input.domainId ?? 'DOM-FIFTH-0001'); + const nodeType = input.nodeType === 'cloud-resident' ? 'cloud-resident' : input.nodeType === 'local-terminal' ? 'local-terminal' : null; + if (!nodeType) throw new Error('domain_access_node_type_invalid'); + return domainAccessOrchestrator.domainAccess(domainId, nodeType); }); ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => { const username = String(input.username || '').trim(); diff --git a/product-source/hololake-desktop/electron/preload.ts b/product-source/hololake-desktop/electron/preload.ts index 2880680..6e0eef7 100644 --- a/product-source/hololake-desktop/electron/preload.ts +++ b/product-source/hololake-desktop/electron/preload.ts @@ -31,7 +31,8 @@ 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), + domainAccess: (domainId: string, nodeType: 'local-terminal' | 'cloud-resident') => + ipcRenderer.invoke('server:domain-access', { domainId, nodeType }), session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId), login: (input: { nodeId: string; username: string; password: string }) => ipcRenderer.invoke('server:login', input), diff --git a/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts b/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts index e460949..1f3df50 100644 --- a/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts +++ b/product-source/hololake-platform/src/lib/domainRuntimeContract.test.ts @@ -65,6 +65,7 @@ function session(overrides: Partial = {}): DomainSessio domainId: 'DOM-FIFTH-0001', expiresAt: NOW + 60_000, nodeId: 'LOCAL-NODE-001', + nodeType: 'LOCAL_TERMINAL_NODE', scopes: ['domain:enter'], ...overrides, } @@ -76,6 +77,7 @@ function receipt(overrides: Partial = {}): DomainConnec domainId: 'DOM-FIFTH-0001', manifestDigest: DIGEST, nodeId: 'LOCAL-NODE-001', + nodeType: 'LOCAL_TERMINAL_NODE', receiptId: 'RECEIPT-001', state: 'online', ...overrides, @@ -141,10 +143,14 @@ describe('domain runtime contract', () => { expect(() => acceptDomainSession(access, session({ domainId: 'DOMAIN-OTHER' }), NOW)) .toThrow('domain_session_domain_mismatch') + expect(() => acceptDomainSession(access, session({ nodeType: 'CLOUD_RESIDENT_NODE' }), NOW)) + .toThrow('domain_session_node_type_mismatch') const withSession = acceptDomainSession(access, session(), NOW) expect(() => acceptDomainConnectionReceipt(withSession, receipt({ domainId: 'DOMAIN-OTHER' }))) .toThrow('domain_connection_domain_mismatch') + expect(() => acceptDomainConnectionReceipt(withSession, receipt({ nodeType: 'CLOUD_RESIDENT_NODE' }))) + .toThrow('domain_connection_node_type_mismatch') }) it('drops execution authority and keeps an explicit read-only scene after disconnect', () => { diff --git a/product-source/hololake-platform/src/lib/domainRuntimeContract.ts b/product-source/hololake-platform/src/lib/domainRuntimeContract.ts index 4013f2e..47b0272 100644 --- a/product-source/hololake-platform/src/lib/domainRuntimeContract.ts +++ b/product-source/hololake-platform/src/lib/domainRuntimeContract.ts @@ -55,6 +55,7 @@ export interface DomainSessionCapability { domainId: string expiresAt: number nodeId: string + nodeType: DomainNodeType scopes: string[] } @@ -63,6 +64,7 @@ export interface DomainConnectionReceipt { domainId: string manifestDigest: string nodeId: string + nodeType: DomainNodeType receiptId: string state: 'online' } @@ -217,6 +219,9 @@ export function acceptDomainSession( if (session.expiresAt <= now) { throw new DomainContractError('domain_session_expired') } + if (session.nodeType !== state.nodeType) { + throw new DomainContractError('domain_session_node_type_mismatch') + } if (!session.scopes.includes(REQUIRED_ENTER_SCOPE)) { throw new DomainContractError('domain_session_enter_scope_missing') } @@ -238,6 +243,9 @@ export function acceptDomainConnectionReceipt( if (receipt.nodeId !== state.session.nodeId) { throw new DomainContractError('domain_connection_node_mismatch') } + if (receipt.nodeType !== state.nodeType || receipt.nodeType !== state.session.nodeType) { + throw new DomainContractError('domain_connection_node_type_mismatch') + } if (receipt.manifestDigest !== state.manifest.provenance.digest) { throw new DomainContractError('domain_connection_manifest_mismatch') }