Add node registration claim transport
This commit is contained in:
parent
b8680e82e1
commit
27cd1645da
8 changed files with 369 additions and 25 deletions
|
|
@ -0,0 +1,177 @@
|
|||
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<NodeRegistrationClaimLookup>): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
type FetchImplementation = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
function invalidTransport(): never {
|
||||
throw new Error('node_registration_claim_transport_invalid');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(record: Record<string, unknown>, 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<NodeRegistrationClaimLookup>,
|
||||
now: number,
|
||||
requestId: string,
|
||||
): Readonly<NodeRegistrationClaimRequestBody> {
|
||||
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<Uint8Array> {
|
||||
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<NodeRegistrationClaimLookup>): Promise<unknown | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue