Bind domain access to registered node type
This commit is contained in:
parent
f3a1ee82a2
commit
8807b99d98
15 changed files with 168 additions and 63 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<DomainIdentityProjection>;
|
||||
read(domainId: string, nodeType: DomainNodeType): Promise<DomainIdentityProjection>;
|
||||
}
|
||||
|
||||
export interface DomainRuntimeHandoffCandidate {
|
||||
|
|
@ -29,7 +31,7 @@ export interface DomainRuntimeHandoffCandidate {
|
|||
}
|
||||
|
||||
export interface DomainRuntimeHandoffSource {
|
||||
read(domainId: string, nodeId: string): Promise<DomainRuntimeHandoffCandidate | null>;
|
||||
read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<DomainRuntimeHandoffCandidate | null>;
|
||||
}
|
||||
|
||||
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<TrustedSignerSnapshotReceipt>;
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<Readonly<DomainAccessProjection>> {
|
||||
async domainAccess(domainId: string, nodeType: DomainNodeType, now = Date.now()): Promise<Readonly<DomainAccessProjection>> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}): 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);
|
||||
|
|
|
|||
|
|
@ -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<object>();
|
||||
|
||||
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<TrustedSignerLookup, 'domainId'>,
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<DomainRuntimeHandoffCandidate>
|
|||
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
||||
constructor(private readonly transport: DomainRuntimeHandoffTransport) {}
|
||||
|
||||
async read(domainId: string, nodeId: string): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
|
||||
async read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<Readonly<DomainRuntimeHandoffCandidate> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, DomainConnectionStep['id']> = {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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')), /已经匹配/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}的独立运行端点与主题包尚未登记;当前只能查看公开门厅。`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function session(overrides: Partial<DomainSessionCapability> = {}): 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<DomainConnectionReceipt> = {}): 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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue