Add node registration claim transport

This commit is contained in:
冰朔 2026-08-10 07:28:11 +08:00
commit 27cd1645da
8 changed files with 369 additions and 25 deletions

View file

@ -191,11 +191,15 @@ test('projects both trust-source states without asking for claims or handoff bef
});
test('a signed node registration without a handoff remains identity-only', async () => {
let observedEndpointId = '';
const orchestrator = new DomainAccessOrchestrator(
signerLoader(),
registrationLoader(),
loggedInIdentity,
validRegistrationSource,
{ async read(request) {
observedEndpointId = request.endpoint.endpointId;
return validRegistrationClaim();
} },
{ async read() { return null; } },
);
@ -203,6 +207,7 @@ test('a signed node registration without a handoff remains identity-only', async
assert.equal(status.stage, 'identity-verified');
assert.equal(status.runtimeReady, false);
assert.equal(observedEndpointId, 'GH-NODE-REG-001');
assert.equal(status.blockers.includes('verified_node_registration_missing'), false);
assert.ok(status.blockers.includes('verified_domain_manifest_missing'));
});
@ -246,13 +251,14 @@ test('current registries, signed node claim and verified handoff project runtime
});
test('source failures are reduced to safe status without raw details', async () => {
let claimReads = 0;
const unavailableSigner = new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret signer detail'); } });
const unavailableRegistration = new NodeRegistrationSnapshotLoader({ async fetchJson() { throw new Error('secret registration detail'); } });
const orchestrator = new DomainAccessOrchestrator(
unavailableSigner,
unavailableRegistration,
loggedInIdentity,
{ async read() { throw new Error('secret claim detail'); } },
{ async read() { claimReads += 1; throw new Error('secret claim detail'); } },
{ async read() { throw new Error('secret handoff detail'); } },
);
@ -262,6 +268,7 @@ test('source failures are reduced to safe status without raw details', async ()
assert.equal(status.runtimeReady, false);
assert.equal(status.nodeRegistrationSource.status, 'UNAVAILABLE');
assert.equal(status.trustSource.status, 'UNAVAILABLE');
assert.equal(claimReads, 0);
assert.equal(JSON.stringify(status).includes('secret'), false);
});

View file

@ -18,6 +18,7 @@ import {
NodeRegistrationSnapshotLoader,
type NodeRegistrationSnapshotReceipt,
} from './node-registration-snapshot.js';
import type { NodeRegistrationClaimSource } from './node-registration-client.js';
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
@ -31,19 +32,6 @@ export interface DomainIdentitySource {
read(domainId: string, nodeType: DomainNodeType): Promise<DomainIdentityProjection>;
}
export interface NodeRegistrationClaimRequest {
accountId: string;
domainId: string;
endpointId: string;
endpointUrl: string;
nodeId: string;
nodeType: DomainNodeType;
}
export interface NodeRegistrationClaimSource {
read(request: Readonly<NodeRegistrationClaimRequest>): Promise<unknown | null>;
}
export interface DomainRuntimeHandoffCandidate {
handoff: unknown;
signerLookup: {
@ -113,8 +101,7 @@ export class DomainAccessOrchestrator {
const claim = await this.nodeRegistrations.read(Object.freeze({
accountId: identity.accountId,
domainId,
endpointId: endpoint.endpointId,
endpointUrl: endpoint.url,
endpoint,
nodeId: identity.nodeId,
nodeType,
}));

View file

@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { HttpNodeRegistrationClaimSource } from './node-registration-client.js';
import {
parseNodeRegistrationEndpointRegistry,
resolveNodeRegistrationEndpoint,
} from './node-registration.js';
const NOW = 1_786_291_200_000;
const COMMIT = 'a'.repeat(40);
const { publicKey } = generateKeyPairSync('ed25519');
function registeredEndpoint() {
const registry = parseNodeRegistrationEndpointRegistry({
endpoints: [{
algorithm: 'Ed25519',
domainIds: ['DOM-FIFTH-0001'],
endpointId: 'GH-NODE-REG-001',
nodeTypes: ['local-terminal'],
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
signerId: 'GH-NODE-REG-SIGNER-001',
status: 'ACTIVE',
url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims',
}],
registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001',
schema: 'gh-aios.node-registration-endpoints/v1',
state: 'CURRENT',
version: '1.0.0',
}, {
repositoryId: 'REPO-012',
sourceCommit: COMMIT,
sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`,
});
const endpoint = resolveNodeRegistrationEndpoint(registry, {
domainId: 'DOM-FIFTH-0001',
nodeType: 'local-terminal',
});
assert.ok(endpoint);
return endpoint;
}
function lookup(endpoint = registeredEndpoint()) {
return {
accountId: 'bingshuo',
domainId: 'DOM-FIFTH-0001',
endpoint,
nodeId: 'LOCAL-001',
nodeType: 'local-terminal' as const,
};
}
function responseBody(status: 'ISSUED' | 'NOT_FOUND' = 'ISSUED', overrides: Record<string, unknown> = {}) {
return {
claim: status === 'ISSUED' ? { signed: 'claim' } : null,
requestId: 'NODE-REQ-001',
schema: 'gh-aios.node-registration-claim-response/v1',
status,
...overrides,
};
}
function jsonResponse(body: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
status: 200,
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
}
test('posts an exact credentialless claim lookup only to the registered endpoint', async () => {
let observedUrl = '';
let observedInit: RequestInit | undefined;
const source = new HttpNodeRegistrationClaimSource(async (url, init) => {
observedUrl = url;
observedInit = init;
return jsonResponse(responseBody());
}, () => NOW, () => 'NODE-REQ-001');
const claim = await source.read(lookup());
assert.deepEqual(claim, { signed: 'claim' });
assert.equal(observedUrl, 'https://guanghulab.com/api/ai/v1/node-registrations/claims');
assert.equal(observedInit?.method, 'POST');
assert.equal(observedInit?.credentials, 'omit');
assert.equal(observedInit?.redirect, 'manual');
assert.equal(observedInit?.cache, 'no-store');
assert.equal(observedInit?.referrerPolicy, 'no-referrer');
assert.deepEqual(JSON.parse(String(observedInit?.body)), {
accountId: 'bingshuo',
domainId: 'DOM-FIFTH-0001',
endpointId: 'GH-NODE-REG-001',
issuedAt: NOW,
nodeId: 'LOCAL-001',
nodeType: 'local-terminal',
requestId: 'NODE-REQ-001',
schema: 'gh-aios.node-registration-claim-request/v1',
});
const headers = observedInit?.headers as Record<string, string>;
assert.equal(headers.Authorization, undefined);
assert.equal(headers.Cookie, undefined);
});
test('returns null only for an exact NOT_FOUND response', async () => {
const source = new HttpNodeRegistrationClaimSource(
async () => jsonResponse(responseBody('NOT_FOUND')),
() => NOW,
() => 'NODE-REQ-001',
);
assert.equal(await source.read(lookup()), null);
});
test('rejects caller-created endpoints before any network request', async () => {
let called = false;
const source = new HttpNodeRegistrationClaimSource(async () => {
called = true;
return jsonResponse(responseBody());
}, () => NOW, () => 'NODE-REQ-001');
const forged = { ...registeredEndpoint(), url: 'https://guanghulab.com/api/ai/v1/other' };
await assert.rejects(source.read(lookup(forged)), /transport_invalid/);
assert.equal(called, false);
});
test('rejects redirects, non-json responses, oversized bodies and mismatched envelopes', async () => {
const cases: Array<() => Response> = [
() => new Response(null, { status: 302, headers: { Location: 'https://guanghulab.com/api/ai/v1/other' } }),
() => new Response('{}', { status: 200, headers: { 'Content-Type': 'text/plain' } }),
() => new Response('x', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Length': '65537' } }),
() => new Response(`"${'x'.repeat(65_536)}"`, { status: 200, headers: { 'Content-Type': 'application/json' } }),
() => jsonResponse(responseBody('ISSUED', { requestId: 'NODE-REQ-OTHER' })),
() => jsonResponse({ ...responseBody(), extra: true }),
() => jsonResponse(responseBody('NOT_FOUND', { claim: {} })),
];
for (const makeResponse of cases) {
const source = new HttpNodeRegistrationClaimSource(
async () => makeResponse(),
() => NOW,
() => 'NODE-REQ-001',
);
await assert.rejects(source.read(lookup()), /transport_invalid/);
}
});
test('reduces network errors to one non-sensitive transport failure', async () => {
const source = new HttpNodeRegistrationClaimSource(async () => {
throw new Error('secret endpoint detail');
}, () => NOW, () => 'NODE-REQ-001');
await assert.rejects(source.read(lookup()), error => {
assert.equal((error as Error).message, 'node_registration_claim_transport_invalid');
return true;
});
});

View file

@ -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);
}
}

View file

@ -204,6 +204,15 @@ export function resolveNodeRegistrationEndpoint(
return endpoint;
}
export function assertRegisteredNodeRegistrationEndpoint(
endpoint: NodeRegistrationEndpoint,
): NodeRegistrationEndpoint {
if (!registeredEndpoints.has(endpoint)) {
throw new Error('node_registration_endpoint_unregistered');
}
return endpoint;
}
export function nodeRegistrationClaimSigningBytes(payload: NodeRegistrationClaimPayload): Buffer {
return Buffer.from(JSON.stringify({
accountId: payload.accountId,