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 NOW = 1_786_291_200_000;
|
||||||
const DOMAIN_ID = 'DOM-FIFTH-0001';
|
const DOMAIN_ID = 'DOM-FIFTH-0001';
|
||||||
const NODE_ID = 'LOCAL-001';
|
const NODE_ID = 'LOCAL-001';
|
||||||
|
const NODE_TYPE = 'local-terminal' as const;
|
||||||
const COMMIT = 'a'.repeat(40);
|
const COMMIT = 'a'.repeat(40);
|
||||||
const ANCHOR_URL = 'https://guanghulab.com/api/ai/v1/anchor';
|
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`;
|
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,
|
domainId: DOMAIN_ID,
|
||||||
manifestDigest: digest,
|
manifestDigest: digest,
|
||||||
nodeId: NODE_ID,
|
nodeId: NODE_ID,
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
receiptId: 'RECEIPT-001',
|
receiptId: 'RECEIPT-001',
|
||||||
state: 'online',
|
state: 'online',
|
||||||
},
|
},
|
||||||
|
|
@ -98,6 +100,7 @@ function validHandoff() {
|
||||||
domainId: DOMAIN_ID,
|
domainId: DOMAIN_ID,
|
||||||
expiresAt: NOW + 60_000,
|
expiresAt: NOW + 60_000,
|
||||||
nodeId: NODE_ID,
|
nodeId: NODE_ID,
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
scopes: ['domain:enter'],
|
scopes: ['domain:enter'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -107,11 +110,11 @@ test('projects public trust health without asking for a handoff before login', a
|
||||||
let handoffReads = 0;
|
let handoffReads = 0;
|
||||||
const orchestrator = new DomainAccessOrchestrator(
|
const orchestrator = new DomainAccessOrchestrator(
|
||||||
loader(),
|
loader(),
|
||||||
{ async read() { return { accountVerified: false, nodeId: '' }; } },
|
{ async read() { return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; } },
|
||||||
{ async read() { handoffReads += 1; return null; } },
|
{ 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(handoffReads, 0);
|
||||||
assert.equal(status.stage, 'login-required');
|
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 () => {
|
test('an authenticated node without a verified handoff remains identity-only', async () => {
|
||||||
const orchestrator = new DomainAccessOrchestrator(
|
const orchestrator = new DomainAccessOrchestrator(
|
||||||
loader(),
|
loader(),
|
||||||
{ async read() { return { accountVerified: true, nodeId: NODE_ID }; } },
|
{ async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } },
|
||||||
{ async read() { return null; } },
|
{ 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.stage, 'identity-verified');
|
||||||
assert.equal(status.runtimeReady, false);
|
assert.equal(status.runtimeReady, false);
|
||||||
assert.ok(status.blockers.includes('verified_domain_manifest_missing'));
|
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 () => {
|
test('a loaded signer snapshot and verified handoff project runtime-ready', async () => {
|
||||||
const orchestrator = new DomainAccessOrchestrator(
|
const orchestrator = new DomainAccessOrchestrator(
|
||||||
loader(() => registry([activeSigner()])),
|
loader(() => registry([activeSigner()])),
|
||||||
{ async read() { return { accountVerified: true, nodeId: NODE_ID }; } },
|
{ async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } },
|
||||||
{ async read() {
|
{ async read() {
|
||||||
return {
|
return {
|
||||||
handoff: validHandoff(),
|
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.stage, 'runtime-ready');
|
||||||
assert.equal(status.runtimeReady, true);
|
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 () => {
|
test('source and handoff failures are reduced to safe status without raw details', async () => {
|
||||||
const orchestrator = new DomainAccessOrchestrator(
|
const orchestrator = new DomainAccessOrchestrator(
|
||||||
new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret source detail'); } }),
|
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'); } },
|
{ 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.stage, 'identity-verified');
|
||||||
assert.equal(status.runtimeReady, false);
|
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' };
|
currentRegistry = { ...registry([activeSigner()]), version: 'invalid' };
|
||||||
const orchestrator = new DomainAccessOrchestrator(
|
const orchestrator = new DomainAccessOrchestrator(
|
||||||
signerLoader,
|
signerLoader,
|
||||||
{ async read() { return { accountVerified: true, nodeId: NODE_ID }; } },
|
{ async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } },
|
||||||
{ async read() {
|
{ async read() {
|
||||||
return {
|
return {
|
||||||
handoff: validHandoff(),
|
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.runtimeReady, true);
|
||||||
assert.equal(status.trustSource.status, 'DEGRADED_LAST_KNOWN_GOOD');
|
assert.equal(status.trustSource.status, 'DEGRADED_LAST_KNOWN_GOOD');
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
evaluateDomainAccess,
|
evaluateDomainAccess,
|
||||||
verifyDomainAccessHandoffFromSnapshot,
|
verifyDomainAccessHandoffFromSnapshot,
|
||||||
type DomainAccessEvidence,
|
type DomainAccessEvidence,
|
||||||
|
type DomainNodeType,
|
||||||
type DomainAccessStatus,
|
type DomainAccessStatus,
|
||||||
} from './domain-access.js';
|
} from './domain-access.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,10 +15,11 @@ const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
export interface DomainIdentityProjection {
|
export interface DomainIdentityProjection {
|
||||||
accountVerified: boolean;
|
accountVerified: boolean;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeRegistrationVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainIdentitySource {
|
export interface DomainIdentitySource {
|
||||||
read(domainId: string): Promise<DomainIdentityProjection>;
|
read(domainId: string, nodeType: DomainNodeType): Promise<DomainIdentityProjection>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffCandidate {
|
export interface DomainRuntimeHandoffCandidate {
|
||||||
|
|
@ -29,7 +31,7 @@ export interface DomainRuntimeHandoffCandidate {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffSource {
|
export interface DomainRuntimeHandoffSource {
|
||||||
read(domainId: string, nodeId: string): Promise<DomainRuntimeHandoffCandidate | null>;
|
read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<DomainRuntimeHandoffCandidate | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainAccessProjection {
|
export interface DomainAccessProjection {
|
||||||
|
|
@ -37,6 +39,7 @@ export interface DomainAccessProjection {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
localWorkspaceAllowed: true;
|
localWorkspaceAllowed: true;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
runtimeReady: boolean;
|
runtimeReady: boolean;
|
||||||
stage: DomainAccessStatus['stage'];
|
stage: DomainAccessStatus['stage'];
|
||||||
trustSource: Readonly<TrustedSignerSnapshotReceipt>;
|
trustSource: Readonly<TrustedSignerSnapshotReceipt>;
|
||||||
|
|
@ -44,7 +47,7 @@ export interface DomainAccessProjection {
|
||||||
|
|
||||||
function safeIdentity(input: unknown): DomainIdentityProjection {
|
function safeIdentity(input: unknown): DomainIdentityProjection {
|
||||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
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 candidate = input as Record<string, unknown>;
|
||||||
const nodeId = typeof candidate.nodeId === 'string' && IDENTIFIER_PATTERN.test(candidate.nodeId)
|
const nodeId = typeof candidate.nodeId === 'string' && IDENTIFIER_PATTERN.test(candidate.nodeId)
|
||||||
|
|
@ -53,6 +56,7 @@ function safeIdentity(input: unknown): DomainIdentityProjection {
|
||||||
return {
|
return {
|
||||||
accountVerified: candidate.accountVerified === true && Boolean(nodeId),
|
accountVerified: candidate.accountVerified === true && Boolean(nodeId),
|
||||||
nodeId,
|
nodeId,
|
||||||
|
nodeRegistrationVerified: candidate.nodeRegistrationVerified === true && Boolean(nodeId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,22 +67,25 @@ export class DomainAccessOrchestrator {
|
||||||
private readonly handoffs: DomainRuntimeHandoffSource,
|
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 (!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([
|
const [snapshot, identity] = await Promise.all([
|
||||||
this.signerSnapshots.refresh(),
|
this.signerSnapshots.refresh(),
|
||||||
this.identities.read(domainId).then(safeIdentity, () => safeIdentity(null)),
|
this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)),
|
||||||
]);
|
]);
|
||||||
const evidence: DomainAccessEvidence = {
|
const evidence: DomainAccessEvidence = {
|
||||||
accountVerified: identity.accountVerified,
|
accountVerified: identity.accountVerified,
|
||||||
domainId,
|
domainId,
|
||||||
nodeId: identity.nodeId,
|
nodeId: identity.nodeId,
|
||||||
|
nodeRegistrationVerified: identity.nodeRegistrationVerified,
|
||||||
|
nodeType,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (identity.accountVerified) {
|
if (identity.accountVerified && identity.nodeRegistrationVerified) {
|
||||||
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
||||||
try {
|
try {
|
||||||
candidate = await this.handoffs.read(domainId, identity.nodeId);
|
candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType);
|
||||||
} catch {
|
} catch {
|
||||||
candidate = null;
|
candidate = null;
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +95,7 @@ export class DomainAccessOrchestrator {
|
||||||
candidate.handoff,
|
candidate.handoff,
|
||||||
domainId,
|
domainId,
|
||||||
identity.nodeId,
|
identity.nodeId,
|
||||||
|
nodeType,
|
||||||
candidate.signerLookup,
|
candidate.signerLookup,
|
||||||
snapshot,
|
snapshot,
|
||||||
now,
|
now,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js';
|
||||||
|
|
||||||
const NOW = 1_786_291_200_000;
|
const NOW = 1_786_291_200_000;
|
||||||
const COMMIT = 'b'.repeat(40);
|
const COMMIT = 'b'.repeat(40);
|
||||||
|
const NODE_TYPE = 'local-terminal' as const;
|
||||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||||
function trustedSignerFromRegistry(overrides: Record<string, unknown> = {}): TrustedManifestSigner {
|
function trustedSignerFromRegistry(overrides: Record<string, unknown> = {}): TrustedManifestSigner {
|
||||||
const parsed = parseTrustedManifestSignerRegistry({
|
const parsed = parseTrustedManifestSignerRegistry({
|
||||||
|
|
@ -62,6 +63,7 @@ const validHandoff = {
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
manifestDigest: DIGEST,
|
manifestDigest: DIGEST,
|
||||||
nodeId: 'LOCAL-001',
|
nodeId: 'LOCAL-001',
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
receiptId: 'RECEIPT-001',
|
receiptId: 'RECEIPT-001',
|
||||||
state: 'online',
|
state: 'online',
|
||||||
},
|
},
|
||||||
|
|
@ -75,6 +77,7 @@ const validHandoff = {
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
expiresAt: NOW + 60_000,
|
expiresAt: NOW + 60_000,
|
||||||
nodeId: 'LOCAL-001',
|
nodeId: 'LOCAL-001',
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
scopes: ['domain:enter'],
|
scopes: ['domain:enter'],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -82,6 +85,7 @@ const verifiedHandoff = parseDomainAccessHandoff(
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
trustedSigner,
|
trustedSigner,
|
||||||
NOW,
|
NOW,
|
||||||
);
|
);
|
||||||
|
|
@ -143,14 +147,21 @@ function snapshotLoader(readRegistry: () => unknown) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a local workspace remains available without claiming domain runtime access', () => {
|
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.localWorkspaceAllowed, true);
|
||||||
assert.equal(status.runtimeReady, false);
|
assert.equal(status.runtimeReady, false);
|
||||||
assert.equal(status.stage, 'login-required');
|
assert.equal(status.stage, 'login-required');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a verified Forgejo account is not a domain runtime session', () => {
|
test('a code-channel account cannot substitute for verified node registration', () => {
|
||||||
const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY' }, NOW);
|
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.equal(status.stage, 'identity-verified');
|
||||||
assert.deepEqual(status.blockers, [
|
assert.deepEqual(status.blockers, [
|
||||||
'verified_domain_manifest_missing',
|
'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', () => {
|
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({
|
const status = evaluateDomainAccess({
|
||||||
accountVerified: true,
|
accountVerified: true,
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
nodeId: 'LOCAL-001',
|
nodeId: 'LOCAL-001',
|
||||||
|
nodeRegistrationVerified: true,
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
...handoff,
|
...handoff,
|
||||||
}, NOW);
|
}, NOW);
|
||||||
assert.equal(status.runtimeReady, true);
|
assert.equal(status.runtimeReady, true);
|
||||||
|
|
@ -178,6 +191,7 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot'
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
||||||
snapshot,
|
snapshot,
|
||||||
NOW,
|
NOW,
|
||||||
|
|
@ -189,6 +203,8 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot'
|
||||||
accountVerified: true,
|
accountVerified: true,
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
nodeId: 'LOCAL-001',
|
nodeId: 'LOCAL-001',
|
||||||
|
nodeRegistrationVerified: true,
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
...verified.handoff,
|
...verified.handoff,
|
||||||
}, NOW).runtimeReady, true);
|
}, NOW).runtimeReady, true);
|
||||||
});
|
});
|
||||||
|
|
@ -199,6 +215,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff',
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
||||||
empty,
|
empty,
|
||||||
NOW,
|
NOW,
|
||||||
|
|
@ -209,6 +226,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff',
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
||||||
{ ...loaded },
|
{ ...loaded },
|
||||||
NOW,
|
NOW,
|
||||||
|
|
@ -217,6 +235,7 @@ test('an empty, forged or mismatched snapshot cannot open the runtime handoff',
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
{ repositoryId: 'REPO-OTHER', signerId: 'GH-LIGHTHOUSE-001' },
|
{ repositoryId: 'REPO-OTHER', signerId: 'GH-LIGHTHOUSE-001' },
|
||||||
loaded,
|
loaded,
|
||||||
NOW,
|
NOW,
|
||||||
|
|
@ -234,6 +253,7 @@ test('a clearly degraded last-known-good snapshot remains usable without claimin
|
||||||
validHandoff,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
{ repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
||||||
degraded,
|
degraded,
|
||||||
NOW,
|
NOW,
|
||||||
|
|
@ -246,11 +266,13 @@ test('a clearly degraded last-known-good snapshot remains usable without claimin
|
||||||
test('mismatched or expired evidence fails closed', () => {
|
test('mismatched or expired evidence fails closed', () => {
|
||||||
const status = evaluateDomainAccess({
|
const status = evaluateDomainAccess({
|
||||||
accountVerified: true,
|
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',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
manifest: verifiedHandoff.manifest,
|
manifest: verifiedHandoff.manifest,
|
||||||
nodeId: 'LOCAL-001',
|
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);
|
}, NOW);
|
||||||
assert.equal(status.runtimeReady, false);
|
assert.equal(status.runtimeReady, false);
|
||||||
assert.ok(status.blockers.includes('scoped_session_capability_missing'));
|
assert.ok(status.blockers.includes('scoped_session_capability_missing'));
|
||||||
|
|
@ -264,7 +286,7 @@ test('the input cannot supply its own verifier receipt', () => {
|
||||||
...validHandoff.manifest,
|
...validHandoff.manifest,
|
||||||
verifierReceipt: { state: 'verified' },
|
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', () => {
|
test('unknown fields and malformed digests fail closed', () => {
|
||||||
|
|
@ -272,22 +294,23 @@ test('unknown fields and malformed digests fail closed', () => {
|
||||||
...validHandoff,
|
...validHandoff,
|
||||||
manifest: { ...validHandoff.manifest, digest: 'not-a-digest' },
|
manifest: { ...validHandoff.manifest, digest: 'not-a-digest' },
|
||||||
trusted: true,
|
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', () => {
|
test('the handoff is bound to the requested domain, node and manifest digest', () => {
|
||||||
assert.throws(() => parseDomainAccessHandoff({
|
assert.throws(() => parseDomainAccessHandoff({
|
||||||
...validHandoff,
|
...validHandoff,
|
||||||
connectionReceipt: { ...validHandoff.connectionReceipt, manifestDigest: 'c'.repeat(64) },
|
connectionReceipt: { ...validHandoff.connectionReceipt, manifestDigest: 'c'.repeat(64) },
|
||||||
}, 'DOM-FIFTH-0001', 'LOCAL-001', 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', 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', () => {
|
test('manifest tampering and signatures from another key fail closed', () => {
|
||||||
assert.throws(() => parseDomainAccessHandoff({
|
assert.throws(() => parseDomainAccessHandoff({
|
||||||
...validHandoff,
|
...validHandoff,
|
||||||
manifest: { ...validHandoff.manifest, sourceCommit: 'c'.repeat(40) },
|
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');
|
const other = generateKeyPairSync('ed25519');
|
||||||
assert.throws(() => parseDomainAccessHandoff({
|
assert.throws(() => parseDomainAccessHandoff({
|
||||||
|
|
@ -296,7 +319,7 @@ test('manifest tampering and signatures from another key fail closed', () => {
|
||||||
...validHandoff.manifest,
|
...validHandoff.manifest,
|
||||||
signature: sign(null, signedBytes, other.privateKey).toString('base64'),
|
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', () => {
|
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,
|
validHandoff,
|
||||||
'DOM-FIFTH-0001',
|
'DOM-FIFTH-0001',
|
||||||
'LOCAL-001',
|
'LOCAL-001',
|
||||||
|
NODE_TYPE,
|
||||||
trustedSignerFromRegistry({ repositoryId: 'REPO-OTHER' }),
|
trustedSignerFromRegistry({ repositoryId: 'REPO-OTHER' }),
|
||||||
NOW,
|
NOW,
|
||||||
), /domain_access_handoff_invalid/);
|
), /domain_access_handoff_invalid/);
|
||||||
|
|
@ -319,6 +343,8 @@ test('copying verified-looking fields cannot bypass the in-process verifier boun
|
||||||
verifierReceipt: { ...verifiedHandoff.manifest.verifierReceipt },
|
verifierReceipt: { ...verifiedHandoff.manifest.verifierReceipt },
|
||||||
},
|
},
|
||||||
nodeId: 'LOCAL-001',
|
nodeId: 'LOCAL-001',
|
||||||
|
nodeRegistrationVerified: true,
|
||||||
|
nodeType: NODE_TYPE,
|
||||||
sessionCapability: verifiedHandoff.sessionCapability,
|
sessionCapability: verifiedHandoff.sessionCapability,
|
||||||
}, NOW);
|
}, NOW);
|
||||||
assert.equal(status.runtimeReady, false);
|
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 COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
|
||||||
const cryptographicallyVerifiedManifests = new WeakSet<object>();
|
const cryptographicallyVerifiedManifests = new WeakSet<object>();
|
||||||
|
|
||||||
|
export type DomainNodeType = 'local-terminal' | 'cloud-resident';
|
||||||
|
|
||||||
export interface DomainManifestEvidence {
|
export interface DomainManifestEvidence {
|
||||||
digest: string;
|
digest: string;
|
||||||
domainId: string;
|
domainId: string;
|
||||||
|
|
@ -46,6 +48,7 @@ export interface DomainSessionCapability {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +57,7 @@ export interface DomainConnectionReceipt {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
manifestDigest: string;
|
manifestDigest: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
receiptId: string;
|
receiptId: string;
|
||||||
state: 'online';
|
state: 'online';
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +79,8 @@ export interface DomainAccessEvidence {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
manifest?: DomainManifestEvidence;
|
manifest?: DomainManifestEvidence;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeRegistrationVerified: boolean;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
sessionCapability?: DomainSessionCapability;
|
sessionCapability?: DomainSessionCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +89,7 @@ export interface DomainAccessStatus {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
localWorkspaceAllowed: true;
|
localWorkspaceAllowed: true;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
runtimeReady: boolean;
|
runtimeReady: boolean;
|
||||||
stage: 'login-required' | 'identity-verified' | 'runtime-ready';
|
stage: 'login-required' | 'identity-verified' | 'runtime-ready';
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +130,7 @@ export function parseDomainAccessHandoff(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
expectedDomainId: string,
|
expectedDomainId: string,
|
||||||
expectedNodeId: string,
|
expectedNodeId: string,
|
||||||
|
expectedNodeType: DomainNodeType,
|
||||||
trustedSigner: TrustedManifestSigner,
|
trustedSigner: TrustedManifestSigner,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
): DomainAccessHandoff {
|
): DomainAccessHandoff {
|
||||||
|
|
@ -132,8 +140,8 @@ export function parseDomainAccessHandoff(
|
||||||
const capability = input.sessionCapability;
|
const capability = input.sessionCapability;
|
||||||
const connection = input.connectionReceipt;
|
const connection = input.connectionReceipt;
|
||||||
if (!isRecord(manifest) || !hasExactKeys(manifest, ['digest', 'domainId', 'repositoryId', 'schema', 'signature', 'signerId', 'sourceCommit'])) invalidHandoff();
|
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(capability) || !hasExactKeys(capability, ['capabilityId', 'domainId', 'expiresAt', 'nodeId', 'nodeType', 'scopes'])) invalidHandoff();
|
||||||
if (!isRecord(connection) || !hasExactKeys(connection, ['connectionId', 'domainId', 'manifestDigest', 'nodeId', 'receiptId', 'state'])) invalidHandoff();
|
if (!isRecord(connection) || !hasExactKeys(connection, ['connectionId', 'domainId', 'manifestDigest', 'nodeId', 'nodeType', 'receiptId', 'state'])) invalidHandoff();
|
||||||
|
|
||||||
const digest = manifest.digest;
|
const digest = manifest.digest;
|
||||||
if (manifest.schema !== MANIFEST_SCHEMA
|
if (manifest.schema !== MANIFEST_SCHEMA
|
||||||
|
|
@ -167,6 +175,7 @@ export function parseDomainAccessHandoff(
|
||||||
if (!isIdentifier(capability.capabilityId)
|
if (!isIdentifier(capability.capabilityId)
|
||||||
|| capability.domainId !== expectedDomainId
|
|| capability.domainId !== expectedDomainId
|
||||||
|| capability.nodeId !== expectedNodeId
|
|| capability.nodeId !== expectedNodeId
|
||||||
|
|| capability.nodeType !== expectedNodeType
|
||||||
|| typeof capability.expiresAt !== 'number' || !Number.isSafeInteger(capability.expiresAt) || capability.expiresAt <= now
|
|| typeof capability.expiresAt !== 'number' || !Number.isSafeInteger(capability.expiresAt) || capability.expiresAt <= now
|
||||||
|| !Array.isArray(capability.scopes)
|
|| !Array.isArray(capability.scopes)
|
||||||
|| capability.scopes.some((scope) => !isIdentifier(scope))
|
|| capability.scopes.some((scope) => !isIdentifier(scope))
|
||||||
|
|
@ -176,6 +185,7 @@ export function parseDomainAccessHandoff(
|
||||||
|| !isIdentifier(connection.receiptId)
|
|| !isIdentifier(connection.receiptId)
|
||||||
|| connection.domainId !== expectedDomainId
|
|| connection.domainId !== expectedDomainId
|
||||||
|| connection.nodeId !== expectedNodeId
|
|| connection.nodeId !== expectedNodeId
|
||||||
|
|| connection.nodeType !== expectedNodeType
|
||||||
|| connection.manifestDigest !== digest
|
|| connection.manifestDigest !== digest
|
||||||
|| connection.state !== 'online') invalidHandoff();
|
|| connection.state !== 'online') invalidHandoff();
|
||||||
|
|
||||||
|
|
@ -201,6 +211,7 @@ export function verifyDomainAccessHandoffFromSnapshot(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
expectedDomainId: string,
|
expectedDomainId: string,
|
||||||
expectedNodeId: string,
|
expectedNodeId: string,
|
||||||
|
expectedNodeType: DomainNodeType,
|
||||||
signerLookup: Omit<TrustedSignerLookup, 'domainId'>,
|
signerLookup: Omit<TrustedSignerLookup, 'domainId'>,
|
||||||
snapshot: TrustedSignerSnapshotResult,
|
snapshot: TrustedSignerSnapshotResult,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
|
@ -221,7 +232,7 @@ export function verifyDomainAccessHandoffFromSnapshot(
|
||||||
});
|
});
|
||||||
if (!trustedSigner) invalidHandoff();
|
if (!trustedSigner) invalidHandoff();
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
handoff: parseDomainAccessHandoff(input, expectedDomainId, expectedNodeId, trustedSigner, now),
|
handoff: parseDomainAccessHandoff(input, expectedDomainId, expectedNodeId, expectedNodeType, trustedSigner, now),
|
||||||
trustSource: source,
|
trustSource: source,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -231,7 +242,8 @@ export function verifyDomainAccessHandoffFromSnapshot(
|
||||||
|
|
||||||
export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.now()): DomainAccessStatus {
|
export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.now()): DomainAccessStatus {
|
||||||
const blockers: string[] = [];
|
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 manifest = evidence.manifest;
|
||||||
const verifier = manifest?.verifierReceipt;
|
const verifier = manifest?.verifierReceipt;
|
||||||
if (!manifest
|
if (!manifest
|
||||||
|
|
@ -249,6 +261,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.
|
||||||
|| !capability.capabilityId
|
|| !capability.capabilityId
|
||||||
|| capability.domainId !== evidence.domainId
|
|| capability.domainId !== evidence.domainId
|
||||||
|| capability.nodeId !== evidence.nodeId
|
|| capability.nodeId !== evidence.nodeId
|
||||||
|
|| capability.nodeType !== evidence.nodeType
|
||||||
|| capability.expiresAt <= now
|
|| capability.expiresAt <= now
|
||||||
|| !capability.scopes.includes('domain:enter')) {
|
|| !capability.scopes.includes('domain:enter')) {
|
||||||
blockers.push('scoped_session_capability_missing');
|
blockers.push('scoped_session_capability_missing');
|
||||||
|
|
@ -260,6 +273,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.
|
||||||
|| receipt.state !== 'online'
|
|| receipt.state !== 'online'
|
||||||
|| receipt.domainId !== evidence.domainId
|
|| receipt.domainId !== evidence.domainId
|
||||||
|| receipt.nodeId !== evidence.nodeId
|
|| receipt.nodeId !== evidence.nodeId
|
||||||
|
|| receipt.nodeType !== evidence.nodeType
|
||||||
|| receipt.manifestDigest !== evidence.manifest?.digest) {
|
|| receipt.manifestDigest !== evidence.manifest?.digest) {
|
||||||
blockers.push('matching_connection_receipt_missing');
|
blockers.push('matching_connection_receipt_missing');
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +283,8 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.
|
||||||
domainId: evidence.domainId,
|
domainId: evidence.domainId,
|
||||||
localWorkspaceAllowed: true,
|
localWorkspaceAllowed: true,
|
||||||
nodeId: evidence.nodeId,
|
nodeId: evidence.nodeId,
|
||||||
|
nodeType: evidence.nodeType,
|
||||||
runtimeReady,
|
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 DOMAIN_ID = 'DOM-FIFTH-0001';
|
||||||
const NODE_ID = 'JD-FD-PRIMARY';
|
const NODE_ID = 'JD-FD-PRIMARY';
|
||||||
|
const NODE_TYPE = 'cloud-resident' as const;
|
||||||
|
|
||||||
function response(overrides: Record<string, unknown> = {}) {
|
function response(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
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, {
|
assert.deepEqual(candidate, {
|
||||||
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
||||||
signerLookup: { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
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) {
|
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(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);
|
assert.equal(requests, 1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import type {
|
||||||
DomainRuntimeHandoffCandidate,
|
DomainRuntimeHandoffCandidate,
|
||||||
DomainRuntimeHandoffSource,
|
DomainRuntimeHandoffSource,
|
||||||
} from './domain-access-orchestrator.js';
|
} from './domain-access-orchestrator.js';
|
||||||
|
import type { DomainNodeType } from './domain-access.js';
|
||||||
|
|
||||||
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffRequest {
|
export interface DomainRuntimeHandoffRequest {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
nodeType: DomainNodeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffTransport {
|
export interface DomainRuntimeHandoffTransport {
|
||||||
|
|
@ -49,10 +51,11 @@ function parseResponse(input: unknown): Readonly<DomainRuntimeHandoffCandidate>
|
||||||
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
||||||
constructor(private readonly transport: DomainRuntimeHandoffTransport) {}
|
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 (!IDENTIFIER_PATTERN.test(domainId) || !IDENTIFIER_PATTERN.test(nodeId)) return null;
|
||||||
|
if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') return null;
|
||||||
try {
|
try {
|
||||||
return parseResponse(await this.transport.request(Object.freeze({ domainId, nodeId })));
|
return parseResponse(await this.transport.request(Object.freeze({ domainId, nodeId, nodeType })));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface DomainAccessStatus {
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
|
nodeType?: DomainNodeType;
|
||||||
runtimeReady: boolean;
|
runtimeReady: boolean;
|
||||||
stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready';
|
stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready';
|
||||||
trustSource?: {
|
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 request = ++domainAccessRequest.current;
|
||||||
const server = (window as any).hololake?.server;
|
const server = (window as any).hololake?.server;
|
||||||
if (!server?.domainAccess) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await server.domainAccess(domainId);
|
const result = await server.domainAccess(domainId, nodeType);
|
||||||
if (request === domainAccessRequest.current) setDomainAccess(result);
|
if (request === domainAccessRequest.current) setDomainAccess(result);
|
||||||
} catch {
|
} 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);
|
setDomainConnectionOpen(true);
|
||||||
if (target) {
|
if (target) {
|
||||||
setDomainAccess({ blockers: [], domainId: target.domain.stableDomainId, runtimeReady: false, stage: 'checking' });
|
setDomainAccess({ blockers: [], domainId: target.domain.stableDomainId, runtimeReady: false, stage: 'checking' });
|
||||||
void refreshDomainAccess(target.domain.stableDomainId);
|
void refreshDomainAccess(target.domain.stableDomainId, target.nodeType);
|
||||||
}
|
}
|
||||||
}, [refreshDomainAccess]);
|
}, [refreshDomainAccess]);
|
||||||
|
|
||||||
const selectDomainNodeType = useCallback((nodeType: DomainNodeType) => {
|
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 () => {
|
const refreshChannel = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { projectDomainConnectionSteps, projectDomainTrustSource } from './domain
|
||||||
test('code-channel account absence cannot be projected as a domain runtime login', () => {
|
test('code-channel account absence cannot be projected as a domain runtime login', () => {
|
||||||
const steps = projectDomainConnectionSteps({
|
const steps = projectDomainConnectionSteps({
|
||||||
blockers: [
|
blockers: [
|
||||||
'account_node_identity_missing',
|
'account_identity_missing',
|
||||||
|
'verified_node_registration_missing',
|
||||||
'verified_domain_manifest_missing',
|
'verified_domain_manifest_missing',
|
||||||
'scoped_session_capability_missing',
|
'scoped_session_capability_missing',
|
||||||
'matching_connection_receipt_missing',
|
'matching_connection_receipt_missing',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export interface DomainAccessProjection {
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
|
nodeType?: 'local-terminal' | 'cloud-resident';
|
||||||
runtimeReady: boolean;
|
runtimeReady: boolean;
|
||||||
stage: DomainAccessStage;
|
stage: DomainAccessStage;
|
||||||
trustSource?: {
|
trustSource?: {
|
||||||
|
|
@ -23,7 +24,8 @@ export interface DomainConnectionStep {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockerToStep: Record<string, DomainConnectionStep['id']> = {
|
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',
|
verified_domain_manifest_missing: 'manifest',
|
||||||
scoped_session_capability_missing: 'capability',
|
scoped_session_capability_missing: 'capability',
|
||||||
matching_connection_receipt_missing: 'receipt',
|
matching_connection_receipt_missing: 'receipt',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { canEnterSelectedDomainRuntime, createDomainEntryTarget, projectDomainRuntimeBoundary } from './domain-entry-state.js';
|
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', () => {
|
test('a generic login request does not silently target the Fifth Domain', () => {
|
||||||
assert.equal(canEnterSelectedDomainRuntime(null, ready('DOM-FIFTH-0001')), false);
|
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', () => {
|
test('the selected stable domain id must match the returned access evidence', () => {
|
||||||
const fifth = createDomainEntryTarget('fifth', 'cloud-resident');
|
const fifth = createDomainEntryTarget('fifth', 'cloud-resident');
|
||||||
assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOMAIN-MAIN')), false);
|
assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOMAIN-MAIN', 'cloud-resident')), false);
|
||||||
assert.match(projectDomainRuntimeBoundary(fifth, ready('DOMAIN-MAIN')), /另一个域/);
|
assert.match(projectDomainRuntimeBoundary(fifth, ready('DOMAIN-MAIN', 'cloud-resident')), /另一个域/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enterprise vestibules cannot reuse the Fifth Domain renderer', () => {
|
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', () => {
|
test('the Fifth Domain opens only with matching runtime-ready evidence', () => {
|
||||||
const fifth = createDomainEntryTarget('fifth', 'cloud-resident');
|
const fifth = createDomainEntryTarget('fifth', 'cloud-resident');
|
||||||
assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOM-FIFTH-0001')), true);
|
assert.equal(canEnterSelectedDomainRuntime(fifth, ready('DOM-FIFTH-0001', 'cloud-resident')), true);
|
||||||
assert.match(projectDomainRuntimeBoundary(fifth, ready('DOM-FIFTH-0001')), /已经匹配/);
|
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 {
|
export function canEnterSelectedDomainRuntime(target: DomainEntryTarget | null, access: DomainAccessProjection): boolean {
|
||||||
if (!target || !access.runtimeReady) return false;
|
if (!target || !access.runtimeReady) return false;
|
||||||
if (access.domainId !== target.domain.stableDomainId) 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
|
// The present desktop bundle contains only the Fifth Domain renderer. The four
|
||||||
// enterprise domains must provide their own signed runtime package and endpoint.
|
// enterprise domains must provide their own signed runtime package and endpoint.
|
||||||
return target.domain.routeId === 'fifth';
|
return target.domain.routeId === 'fifth';
|
||||||
|
|
@ -27,6 +28,9 @@ export function projectDomainRuntimeBoundary(target: DomainEntryTarget | null, a
|
||||||
if (access.domainId && access.domainId !== target.domain.stableDomainId) {
|
if (access.domainId && access.domainId !== target.domain.stableDomainId) {
|
||||||
return '回读证据属于另一个域;当前入口保持关闭。';
|
return '回读证据属于另一个域;当前入口保持关闭。';
|
||||||
}
|
}
|
||||||
|
if (access.nodeType && access.nodeType !== target.nodeType) {
|
||||||
|
return '回读证据属于另一种节点类型;当前入口保持关闭并重新核验。';
|
||||||
|
}
|
||||||
if (target.domain.routeId !== 'fifth') {
|
if (target.domain.routeId !== 'fifth') {
|
||||||
return `${target.domain.displayName}的独立运行端点与主题包尚未登记;当前只能查看公开门厅。`;
|
return `${target.domain.displayName}的独立运行端点与主题包尚未登记;当前只能查看公开门厅。`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -329,14 +329,14 @@ const domainAccessOrchestrator = new DomainAccessOrchestrator(
|
||||||
{
|
{
|
||||||
async read() {
|
async read() {
|
||||||
const stored = applyStoredServerAuth();
|
const stored = applyStoredServerAuth();
|
||||||
if (!stored) {
|
if (!stored) return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false };
|
||||||
return { accountVerified: false, nodeId: defaultPersonalServerId() || '' };
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { response } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token });
|
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 {
|
} 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 };
|
return { authenticated: false, nodeId };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcMain.handle('server:domain-access', async (_event, domainId = 'DOM-FIFTH-0001') => {
|
ipcMain.handle('server:domain-access', async (_event, input: { domainId?: unknown; nodeType?: unknown } = {}) => {
|
||||||
return domainAccessOrchestrator.domainAccess(String(domainId));
|
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 }) => {
|
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
|
||||||
const username = String(input.username || '').trim();
|
const username = String(input.username || '').trim();
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ contextBridge.exposeInMainWorld('hololake', {
|
||||||
list: () => ipcRenderer.invoke('server:list'),
|
list: () => ipcRenderer.invoke('server:list'),
|
||||||
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
||||||
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
||||||
domainAccess: (domainId: string) => ipcRenderer.invoke('server:domain-access', domainId),
|
domainAccess: (domainId: string, nodeType: 'local-terminal' | 'cloud-resident') =>
|
||||||
|
ipcRenderer.invoke('server:domain-access', { domainId, nodeType }),
|
||||||
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
||||||
login: (input: { nodeId: string; username: string; password: string }) =>
|
login: (input: { nodeId: string; username: string; password: string }) =>
|
||||||
ipcRenderer.invoke('server:login', input),
|
ipcRenderer.invoke('server:login', input),
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ function session(overrides: Partial<DomainSessionCapability> = {}): DomainSessio
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
expiresAt: NOW + 60_000,
|
expiresAt: NOW + 60_000,
|
||||||
nodeId: 'LOCAL-NODE-001',
|
nodeId: 'LOCAL-NODE-001',
|
||||||
|
nodeType: 'LOCAL_TERMINAL_NODE',
|
||||||
scopes: ['domain:enter'],
|
scopes: ['domain:enter'],
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +77,7 @@ function receipt(overrides: Partial<DomainConnectionReceipt> = {}): DomainConnec
|
||||||
domainId: 'DOM-FIFTH-0001',
|
domainId: 'DOM-FIFTH-0001',
|
||||||
manifestDigest: DIGEST,
|
manifestDigest: DIGEST,
|
||||||
nodeId: 'LOCAL-NODE-001',
|
nodeId: 'LOCAL-NODE-001',
|
||||||
|
nodeType: 'LOCAL_TERMINAL_NODE',
|
||||||
receiptId: 'RECEIPT-001',
|
receiptId: 'RECEIPT-001',
|
||||||
state: 'online',
|
state: 'online',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
@ -141,10 +143,14 @@ describe('domain runtime contract', () => {
|
||||||
|
|
||||||
expect(() => acceptDomainSession(access, session({ domainId: 'DOMAIN-OTHER' }), NOW))
|
expect(() => acceptDomainSession(access, session({ domainId: 'DOMAIN-OTHER' }), NOW))
|
||||||
.toThrow('domain_session_domain_mismatch')
|
.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)
|
const withSession = acceptDomainSession(access, session(), NOW)
|
||||||
expect(() => acceptDomainConnectionReceipt(withSession, receipt({ domainId: 'DOMAIN-OTHER' })))
|
expect(() => acceptDomainConnectionReceipt(withSession, receipt({ domainId: 'DOMAIN-OTHER' })))
|
||||||
.toThrow('domain_connection_domain_mismatch')
|
.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', () => {
|
it('drops execution authority and keeps an explicit read-only scene after disconnect', () => {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export interface DomainSessionCapability {
|
||||||
domainId: string
|
domainId: string
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
nodeId: string
|
nodeId: string
|
||||||
|
nodeType: DomainNodeType
|
||||||
scopes: string[]
|
scopes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ export interface DomainConnectionReceipt {
|
||||||
domainId: string
|
domainId: string
|
||||||
manifestDigest: string
|
manifestDigest: string
|
||||||
nodeId: string
|
nodeId: string
|
||||||
|
nodeType: DomainNodeType
|
||||||
receiptId: string
|
receiptId: string
|
||||||
state: 'online'
|
state: 'online'
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +219,9 @@ export function acceptDomainSession(
|
||||||
if (session.expiresAt <= now) {
|
if (session.expiresAt <= now) {
|
||||||
throw new DomainContractError('domain_session_expired')
|
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)) {
|
if (!session.scopes.includes(REQUIRED_ENTER_SCOPE)) {
|
||||||
throw new DomainContractError('domain_session_enter_scope_missing')
|
throw new DomainContractError('domain_session_enter_scope_missing')
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +243,9 @@ export function acceptDomainConnectionReceipt(
|
||||||
if (receipt.nodeId !== state.session.nodeId) {
|
if (receipt.nodeId !== state.session.nodeId) {
|
||||||
throw new DomainContractError('domain_connection_node_mismatch')
|
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) {
|
if (receipt.manifestDigest !== state.manifest.provenance.digest) {
|
||||||
throw new DomainContractError('domain_connection_manifest_mismatch')
|
throw new DomainContractError('domain_connection_manifest_mismatch')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue