import { randomUUID } from 'node:crypto'; import type { DomainNodeType } from './domain-access.js'; import { assertRegisteredNodeRegistrationEndpoint, type NodeRegistrationEndpoint, } from './node-registration.js'; const REQUEST_SCHEMA = 'gh-aios.node-registration-claim-request/v1' as const; const RESPONSE_SCHEMA = 'gh-aios.node-registration-claim-response/v1' as const; const MAX_RESPONSE_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 5_000; const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; export interface NodeRegistrationClaimLookup { accountId: string; domainId: string; endpoint: NodeRegistrationEndpoint; nodeId: string; nodeType: DomainNodeType; } export interface NodeRegistrationClaimRequestBody { accountId: string; domainId: string; endpointId: string; issuedAt: number; nodeId: string; nodeType: DomainNodeType; requestId: string; schema: typeof REQUEST_SCHEMA; } export interface NodeRegistrationClaimSource { read(lookup: Readonly): Promise; } type FetchImplementation = (input: string, init: RequestInit) => Promise; function invalidTransport(): never { throw new Error('node_registration_claim_transport_invalid'); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasExactKeys(record: Record, keys: string[]): boolean { const expected = [...keys].sort(); const actual = Object.keys(record).sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } function buildRequest( lookup: Readonly, now: number, requestId: string, ): Readonly { let endpoint: NodeRegistrationEndpoint; try { endpoint = assertRegisteredNodeRegistrationEndpoint(lookup.endpoint); } catch { invalidTransport(); } if (endpoint.status !== 'ACTIVE' || !endpoint.domainIds.includes(lookup.domainId) || !endpoint.nodeTypes.includes(lookup.nodeType) || !ACCOUNT_ID_PATTERN.test(lookup.accountId) || !IDENTIFIER_PATTERN.test(lookup.domainId) || !IDENTIFIER_PATTERN.test(lookup.nodeId) || !IDENTIFIER_PATTERN.test(requestId) || !Number.isSafeInteger(now)) invalidTransport(); return Object.freeze({ accountId: lookup.accountId, domainId: lookup.domainId, endpointId: endpoint.endpointId, issuedAt: now, nodeId: lookup.nodeId, nodeType: lookup.nodeType, requestId, schema: REQUEST_SCHEMA, }); } async function readLimitedBody(response: Response): Promise { if (!response.body) invalidTransport(); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_RESPONSE_BYTES) invalidTransport(); chunks.push(value); } } finally { reader.releaseLock(); } const body = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } return body; } function parseResponse(input: unknown, request: NodeRegistrationClaimRequestBody): unknown | null { if (!isRecord(input) || !hasExactKeys(input, ['claim', 'requestId', 'schema', 'status']) || input.schema !== RESPONSE_SCHEMA || input.requestId !== request.requestId || (input.status !== 'ISSUED' && input.status !== 'NOT_FOUND')) invalidTransport(); if (input.status === 'NOT_FOUND') { if (input.claim !== null) invalidTransport(); return null; } if (!isRecord(input.claim)) invalidTransport(); return input.claim; } export class HttpNodeRegistrationClaimSource implements NodeRegistrationClaimSource { constructor( private readonly fetchImpl: FetchImplementation = fetch, private readonly now: () => number = Date.now, private readonly requestId: () => string = () => `NODE-REQ-${randomUUID().toUpperCase()}`, ) {} async read(lookup: Readonly): Promise { const issuedAt = this.now(); const request = buildRequest(lookup, issuedAt, this.requestId()); let response: Response; try { response = await this.fetchImpl(lookup.endpoint.url, { body: JSON.stringify(request), cache: 'no-store', credentials: 'omit', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, method: 'POST', redirect: 'manual', referrerPolicy: 'no-referrer', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch { invalidTransport(); } if (response.status !== 200 || response.redirected || !response.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { invalidTransport(); } const contentLength = response.headers.get('content-length'); if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) { invalidTransport(); } let decoded: string; try { decoded = new TextDecoder('utf-8', { fatal: true }).decode(await readLimitedBody(response)); } catch { invalidTransport(); } let parsed: unknown; try { parsed = JSON.parse(decoded); } catch { invalidTransport(); } if (Math.abs(this.now() - issuedAt) > MAX_CLOCK_SKEW_MS) invalidTransport(); return parseResponse(parsed, request); } }