180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import {
|
|
evaluateDomainAccess,
|
|
verifyDomainAccessHandoffFromSnapshot,
|
|
type DomainAccessEvidence,
|
|
type DomainNodeType,
|
|
type DomainAccessStatus,
|
|
} from './domain-access.js';
|
|
import {
|
|
TrustedSignerSnapshotLoader,
|
|
type TrustedSignerSnapshotReceipt,
|
|
} from './trusted-signer-snapshot.js';
|
|
import {
|
|
resolveNodeRegistrationEndpoint,
|
|
verifyNodeRegistrationClaim,
|
|
} from './node-registration.js';
|
|
import {
|
|
assertLoadedNodeRegistrationSnapshot,
|
|
NodeRegistrationSnapshotLoader,
|
|
type NodeRegistrationSnapshotReceipt,
|
|
} from './node-registration-snapshot.js';
|
|
import type { NodeRegistrationClaimSource } from './node-registration-client.js';
|
|
import {
|
|
createNodePossessionChallenge,
|
|
verifyNodePossessionResponse,
|
|
type NodePossessionProofSource,
|
|
} from './node-possession-proof.js';
|
|
|
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
|
|
|
export interface DomainIdentityProjection {
|
|
accountId: string;
|
|
accountVerified: boolean;
|
|
nodeId: string;
|
|
}
|
|
|
|
export interface DomainIdentitySource {
|
|
read(domainId: string, nodeType: DomainNodeType): Promise<DomainIdentityProjection>;
|
|
}
|
|
|
|
export interface DomainRuntimeHandoffCandidate {
|
|
handoff: unknown;
|
|
signerLookup: {
|
|
repositoryId: string;
|
|
signerId: string;
|
|
};
|
|
}
|
|
|
|
export interface DomainRuntimeHandoffSource {
|
|
read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<DomainRuntimeHandoffCandidate | null>;
|
|
}
|
|
|
|
export interface DomainAccessProjection {
|
|
blockers: readonly string[];
|
|
domainId: string;
|
|
localWorkspaceAllowed: true;
|
|
nodeId: string;
|
|
nodeType: DomainNodeType;
|
|
nodeRegistrationSource: Readonly<NodeRegistrationSnapshotReceipt>;
|
|
runtimeReady: boolean;
|
|
stage: DomainAccessStatus['stage'];
|
|
trustSource: Readonly<TrustedSignerSnapshotReceipt>;
|
|
}
|
|
|
|
function safeIdentity(input: unknown): DomainIdentityProjection {
|
|
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
return { accountId: '', accountVerified: false, nodeId: '' };
|
|
}
|
|
const candidate = input as Record<string, unknown>;
|
|
const accountId = typeof candidate.accountId === 'string'
|
|
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(candidate.accountId)
|
|
? candidate.accountId
|
|
: '';
|
|
const nodeId = typeof candidate.nodeId === 'string' && IDENTIFIER_PATTERN.test(candidate.nodeId)
|
|
? candidate.nodeId
|
|
: '';
|
|
return {
|
|
accountId,
|
|
accountVerified: candidate.accountVerified === true && Boolean(accountId) && Boolean(nodeId),
|
|
nodeId,
|
|
};
|
|
}
|
|
|
|
export class DomainAccessOrchestrator {
|
|
constructor(
|
|
private readonly signerSnapshots: TrustedSignerSnapshotLoader,
|
|
private readonly nodeRegistrationSnapshots: NodeRegistrationSnapshotLoader,
|
|
private readonly identities: DomainIdentitySource,
|
|
private readonly nodeRegistrations: NodeRegistrationClaimSource,
|
|
private readonly nodePossessions: NodePossessionProofSource,
|
|
private readonly handoffs: DomainRuntimeHandoffSource,
|
|
private readonly clock: () => number = Date.now,
|
|
) {}
|
|
|
|
async domainAccess(domainId: string, nodeType: DomainNodeType, at?: number): Promise<Readonly<DomainAccessProjection>> {
|
|
let now = at ?? this.clock();
|
|
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, nodeRegistrationSnapshot, identity] = await Promise.all([
|
|
this.signerSnapshots.refresh(),
|
|
this.nodeRegistrationSnapshots.refresh(),
|
|
this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)),
|
|
]);
|
|
let nodeRegistrationVerified = false;
|
|
let nodePossessionVerified = false;
|
|
if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') {
|
|
try {
|
|
const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot);
|
|
const endpoint = loaded.registry && resolveNodeRegistrationEndpoint(loaded.registry, { domainId, nodeType });
|
|
if (endpoint) {
|
|
const claim = await this.nodeRegistrations.read(Object.freeze({
|
|
accountId: identity.accountId,
|
|
domainId,
|
|
endpoint,
|
|
nodeId: identity.nodeId,
|
|
nodeType,
|
|
}));
|
|
if (claim) {
|
|
const registration = verifyNodeRegistrationClaim(claim, {
|
|
accountId: identity.accountId,
|
|
domainId,
|
|
nodeId: identity.nodeId,
|
|
nodeType,
|
|
}, endpoint, now);
|
|
nodeRegistrationVerified = true;
|
|
const challenge = createNodePossessionChallenge(registration, now);
|
|
const response = await this.nodePossessions.prove(challenge);
|
|
if (response) {
|
|
if (at === undefined) now = this.clock();
|
|
verifyNodePossessionResponse(response, challenge, registration, now);
|
|
nodePossessionVerified = true;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
nodeRegistrationVerified = false;
|
|
}
|
|
}
|
|
const evidence: DomainAccessEvidence = {
|
|
accountVerified: identity.accountVerified,
|
|
domainId,
|
|
nodeId: identity.nodeId,
|
|
nodePossessionVerified,
|
|
nodeRegistrationVerified,
|
|
nodeType,
|
|
};
|
|
|
|
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified) {
|
|
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
|
try {
|
|
candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType);
|
|
} catch {
|
|
candidate = null;
|
|
}
|
|
if (candidate) {
|
|
try {
|
|
const verified = verifyDomainAccessHandoffFromSnapshot(
|
|
candidate.handoff,
|
|
domainId,
|
|
identity.nodeId,
|
|
nodeType,
|
|
candidate.signerLookup,
|
|
snapshot,
|
|
now,
|
|
);
|
|
Object.assign(evidence, verified.handoff);
|
|
} catch {
|
|
// Untrusted or incomplete handoffs remain absent from the projection.
|
|
}
|
|
}
|
|
}
|
|
|
|
const status = evaluateDomainAccess(evidence, now);
|
|
return Object.freeze({
|
|
...status,
|
|
blockers: Object.freeze([...status.blockers]),
|
|
nodeRegistrationSource: nodeRegistrationSnapshot.receipt,
|
|
trustSource: snapshot.receipt,
|
|
});
|
|
}
|
|
}
|