206 lines
8.3 KiB
TypeScript
206 lines
8.3 KiB
TypeScript
import type {
|
|
DomainRuntimeHandoffCandidate,
|
|
DomainRuntimeHandoffSource,
|
|
} from './domain-access-orchestrator.js';
|
|
import {
|
|
assertVerifiedNodePossession,
|
|
type NodePossessionChallenge,
|
|
type NodePossessionResponse,
|
|
type VerifiedNodePossession,
|
|
} from './node-possession-proof.js';
|
|
import type { VerifiedNodeRegistration } from './node-registration.js';
|
|
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
assertRegisteredDomainRuntimeHandoffEndpoint,
|
|
resolveDomainRuntimeHandoffEndpoint,
|
|
type DomainRuntimeHandoffEndpoint,
|
|
} from './domain-runtime-handoff-registry.js';
|
|
import {
|
|
assertLoadedDomainRuntimeHandoffSnapshot,
|
|
type DomainRuntimeHandoffSnapshotLoader,
|
|
} from './domain-runtime-handoff-snapshot.js';
|
|
|
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
|
const REQUEST_SCHEMA = 'gh-aios.domain-runtime-handoff-request/v1' as const;
|
|
const RESPONSE_SCHEMA = 'gh-aios.domain-runtime-handoff-response/v1' as const;
|
|
const consumedPossessions = new WeakSet<object>();
|
|
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
|
|
export interface DomainRuntimeHandoffRequest {
|
|
accountId: string;
|
|
challenge: Readonly<NodePossessionChallenge>;
|
|
domainId: string;
|
|
endpointId: string;
|
|
issuedAt: number;
|
|
nodeId: string;
|
|
nodeType: VerifiedNodeRegistration['nodeType'];
|
|
registration: Readonly<VerifiedNodeRegistration>;
|
|
requestId: string;
|
|
response: Readonly<NodePossessionResponse>;
|
|
schema: typeof REQUEST_SCHEMA;
|
|
}
|
|
|
|
export interface DomainRuntimeHandoffTransport {
|
|
request(
|
|
endpoint: Readonly<DomainRuntimeHandoffEndpoint>,
|
|
input: Readonly<DomainRuntimeHandoffRequest>,
|
|
): Promise<unknown>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
|
const actual = Object.keys(value).sort();
|
|
const keys = [...expected].sort();
|
|
return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
|
|
}
|
|
|
|
function parseResponse(
|
|
input: unknown,
|
|
request: DomainRuntimeHandoffRequest,
|
|
): Readonly<DomainRuntimeHandoffCandidate> | null {
|
|
if (!isRecord(input)
|
|
|| !hasExactKeys(input, ['handoff', 'request_id', 'schema', 'signer_lookup', 'status'])
|
|
|| input.schema !== RESPONSE_SCHEMA
|
|
|| input.request_id !== request.requestId
|
|
|| (input.status !== 'ISSUED' && input.status !== 'NOT_AUTHORIZED')) {
|
|
return null;
|
|
}
|
|
if (input.status === 'NOT_AUTHORIZED') {
|
|
if (input.handoff !== null || input.signer_lookup !== null) return null;
|
|
return null;
|
|
}
|
|
if (!isRecord(input.handoff)
|
|
|| !isRecord(input.signer_lookup)
|
|
|| !hasExactKeys(input.signer_lookup, ['repository_id', 'signer_id'])
|
|
|| typeof input.signer_lookup.repository_id !== 'string'
|
|
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.repository_id)
|
|
|| typeof input.signer_lookup.signer_id !== 'string'
|
|
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.signer_id)) return null;
|
|
return Object.freeze({
|
|
handoff: input.handoff,
|
|
signerLookup: Object.freeze({
|
|
repositoryId: input.signer_lookup.repository_id,
|
|
signerId: input.signer_lookup.signer_id,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
|
constructor(
|
|
private readonly endpoint: DomainRuntimeHandoffEndpoint,
|
|
private readonly transport: DomainRuntimeHandoffTransport,
|
|
private readonly clock: () => number = Date.now,
|
|
private readonly requestId: () => string = () => `HANDOFF-REQ-${randomUUID().toUpperCase()}`,
|
|
) {}
|
|
|
|
async read(possession: VerifiedNodePossession): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
|
|
try {
|
|
const verified = assertVerifiedNodePossession(possession);
|
|
if (consumedPossessions.has(verified)) return null;
|
|
const endpoint = assertRegisteredDomainRuntimeHandoffEndpoint(this.endpoint);
|
|
if (endpoint.status !== 'ACTIVE'
|
|
|| !endpoint.domainIds.includes(verified.registration.domainId)
|
|
|| !endpoint.nodeTypes.includes(verified.registration.nodeType)) return null;
|
|
const issuedAt = this.clock();
|
|
const requestId = this.requestId();
|
|
if (!Number.isSafeInteger(issuedAt)
|
|
|| issuedAt < verified.verifiedAt
|
|
|| issuedAt >= verified.challenge.expiresAt
|
|
|| issuedAt >= verified.registration.expiresAt
|
|
|| !IDENTIFIER_PATTERN.test(requestId)) return null;
|
|
const request = Object.freeze({
|
|
accountId: verified.registration.accountId,
|
|
challenge: verified.challenge,
|
|
domainId: verified.registration.domainId,
|
|
endpointId: endpoint.endpointId,
|
|
issuedAt,
|
|
nodeId: verified.registration.nodeId,
|
|
nodeType: verified.registration.nodeType,
|
|
registration: verified.registration,
|
|
requestId,
|
|
response: verified.response,
|
|
schema: REQUEST_SCHEMA,
|
|
});
|
|
consumedPossessions.add(verified);
|
|
return parseResponse(await this.transport.request(endpoint, request), request);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class AnchoredDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
|
constructor(
|
|
private readonly snapshots: DomainRuntimeHandoffSnapshotLoader,
|
|
private readonly transport: DomainRuntimeHandoffTransport,
|
|
private readonly clock: () => number = Date.now,
|
|
private readonly requestId: () => string = () => `HANDOFF-REQ-${randomUUID().toUpperCase()}`,
|
|
) {}
|
|
|
|
async read(possession: VerifiedNodePossession): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
|
|
try {
|
|
const verified = assertVerifiedNodePossession(possession);
|
|
const snapshot = assertLoadedDomainRuntimeHandoffSnapshot(await this.snapshots.refresh());
|
|
if (snapshot.receipt.status !== 'CURRENT' || !snapshot.registry) return null;
|
|
const endpoint = resolveDomainRuntimeHandoffEndpoint(snapshot.registry, {
|
|
domainId: verified.registration.domainId,
|
|
nodeType: verified.registration.nodeType,
|
|
});
|
|
if (!endpoint) return null;
|
|
return new StrictDomainRuntimeHandoffSource(
|
|
endpoint,
|
|
this.transport,
|
|
this.clock,
|
|
this.requestId,
|
|
).read(verified);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class HttpDomainRuntimeHandoffTransport implements DomainRuntimeHandoffTransport {
|
|
constructor(private readonly fetcher: typeof fetch = fetch) {}
|
|
|
|
async request(
|
|
inputEndpoint: Readonly<DomainRuntimeHandoffEndpoint>,
|
|
input: Readonly<DomainRuntimeHandoffRequest>,
|
|
): Promise<unknown> {
|
|
const endpoint = assertRegisteredDomainRuntimeHandoffEndpoint(inputEndpoint);
|
|
if (endpoint.status !== 'ACTIVE'
|
|
|| input.endpointId !== endpoint.endpointId
|
|
|| !endpoint.domainIds.includes(input.domainId)
|
|
|| !endpoint.nodeTypes.includes(input.nodeType)) {
|
|
throw new Error('domain_runtime_handoff_endpoint_scope_invalid');
|
|
}
|
|
const response = await this.fetcher(endpoint.url, {
|
|
body: JSON.stringify(input),
|
|
cache: 'no-store',
|
|
credentials: 'omit',
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
redirect: 'manual',
|
|
referrerPolicy: 'no-referrer',
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!response.ok || response.status < 200 || response.status >= 300
|
|
|| response.type === 'opaqueredirect') throw new Error('domain_runtime_handoff_http_rejected');
|
|
const contentType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
if (contentType !== 'application/json') throw new Error('domain_runtime_handoff_content_type_invalid');
|
|
const declaredLength = response.headers.get('content-length');
|
|
if (declaredLength !== null
|
|
&& (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_RESPONSE_BYTES)) {
|
|
throw new Error('domain_runtime_handoff_response_too_large');
|
|
}
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
if (bytes.byteLength > MAX_RESPONSE_BYTES) throw new Error('domain_runtime_handoff_response_too_large');
|
|
let text: string;
|
|
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
catch { throw new Error('domain_runtime_handoff_response_encoding_invalid'); }
|
|
try { return JSON.parse(text); }
|
|
catch { throw new Error('domain_runtime_handoff_response_json_invalid'); }
|
|
}
|
|
}
|