Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
import { redactOperationPayload } from '../src/redactor.ts';
|
|
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
|
|
|
export const UPSTREAM_BASELINE = '58e2204';
|
|
export const DENY_REASONS: Record<string, string> = {
|
|
'/api/network/operators/scan': 'active radio/network scan',
|
|
'/api/connectivity': 'handler performs active connectivity ping',
|
|
'/api/sms/list': 'message list can expose body; intentionally not collected',
|
|
'/api/sms/conversation': 'requires correspondent query and exposes message bodies',
|
|
'/api/call/history': 'query limit is not encoded in Registry path contract',
|
|
'/api/notifications/logs': 'query limit is not encoded in Registry path contract',
|
|
'/api/notifications/queue': 'query limit is not encoded in Registry path contract',
|
|
'/api/automation/logs': 'query limit is not encoded in Registry path contract',
|
|
'/api/device-network/ddns/logs': 'no safe limit contract',
|
|
'/api/esim/euicc': 'query-bearing endpoint omitted',
|
|
'/api/esim/profiles': 'query-bearing endpoint omitted',
|
|
};
|
|
const registryById = new Map(upstream58e2204Operations.map((op: any) => [op.operationId, op]));
|
|
// Capture the complete imported JSON contract before any caller can mutate it.
|
|
// Exact identity alone does not protect mutable Registry objects.
|
|
const registrySignatures = new Map(
|
|
upstream58e2204Operations.map((op: any) => [op.operationId, JSON.stringify(op)]),
|
|
);
|
|
function isPrivateLan(host: string) {
|
|
const p = host.split('.').map(Number);
|
|
return (
|
|
p.length === 4 &&
|
|
p.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) &&
|
|
(p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
|
|
);
|
|
}
|
|
export function validateInstanceOrigin(origin: string) {
|
|
let u: URL;
|
|
try {
|
|
u = new URL(origin);
|
|
} catch {
|
|
throw new Error('invalid collector instance origin');
|
|
}
|
|
if (
|
|
!['http:', 'https:'].includes(u.protocol) ||
|
|
!isPrivateLan(u.hostname) ||
|
|
u.pathname !== '/' ||
|
|
u.search ||
|
|
u.hash ||
|
|
u.username ||
|
|
u.password
|
|
)
|
|
throw new Error('invalid collector instance origin');
|
|
return u.origin;
|
|
}
|
|
function validatePath(path: string) {
|
|
if (
|
|
typeof path !== 'string' ||
|
|
!path.startsWith('/api/') ||
|
|
path.startsWith('//') ||
|
|
path.includes('\\') ||
|
|
/[?#%]/.test(path) ||
|
|
path.includes('//')
|
|
)
|
|
throw new Error('unsafe operation path');
|
|
let decoded = path;
|
|
for (let i = 0; i < 2; i++) {
|
|
const next = decodeURIComponent(decoded);
|
|
if (next !== decoded) throw new Error('encoded operation path');
|
|
decoded = next;
|
|
}
|
|
if (path.split('/').some((x) => x === '.' || x === '..'))
|
|
throw new Error('dot segment operation path');
|
|
return path;
|
|
}
|
|
export function validateReadonlyOperation(op: any) {
|
|
const exact = registryById.get(op?.operationId);
|
|
if (!exact || exact !== op || registrySignatures.get(op?.operationId) !== JSON.stringify(op))
|
|
throw new Error('operation violates Registry integrity');
|
|
validatePath(op.pathTemplate);
|
|
if (op.method !== 'GET' || op.riskLevel !== 'R0' || /[{}]/.test(op.pathTemplate))
|
|
throw new Error('operation is not readonly R0');
|
|
if (DENY_REASONS[op.pathTemplate]) throw new Error('operation is explicitly denied');
|
|
return op;
|
|
}
|
|
export function selectReadonlyOperations(registry: any[]) {
|
|
const candidates = registry.filter(
|
|
(o) => o.method === 'GET' && o.riskLevel === 'R0' && !/[{}]/.test(o.pathTemplate),
|
|
);
|
|
for (const op of candidates) validatePath(op.pathTemplate);
|
|
return {
|
|
selected: candidates
|
|
.filter((o) => !DENY_REASONS[o.pathTemplate])
|
|
.map(validateReadonlyOperation),
|
|
denied: candidates
|
|
.filter((o) => DENY_REASONS[o.pathTemplate])
|
|
.map((o) => ({ ...o, denyReason: DENY_REASONS[o.pathTemplate] })),
|
|
};
|
|
}
|
|
function latency(ms: number, timedOut = false) {
|
|
if (timedOut) return 'timeout';
|
|
if (ms < 250) return '<250ms';
|
|
if (ms < 1000) return '250ms-1s';
|
|
if (ms < 3000) return '1-3s';
|
|
return '>3s';
|
|
}
|
|
function statusCategory(status: number, isJson: boolean) {
|
|
if (status === 401 || status === 403) return 'auth-required';
|
|
if (status === 404 || status === 405 || status === 501) return 'unsupported';
|
|
if (status >= 200 && status < 300) return isJson ? 'success' : 'non-json';
|
|
return 'http-error';
|
|
}
|
|
export async function collectOne(
|
|
instance: { origin: string; alias: string },
|
|
op: any,
|
|
transport: any = fetch,
|
|
) {
|
|
validateReadonlyOperation(op);
|
|
if (!['instance-1', 'instance-2'].includes(instance?.alias))
|
|
throw new Error('invalid instance alias');
|
|
const origin = validateInstanceOrigin(instance?.origin);
|
|
const requestUrl = new URL(op.pathTemplate, origin);
|
|
if (
|
|
requestUrl.origin !== origin ||
|
|
requestUrl.pathname !== op.pathTemplate ||
|
|
requestUrl.search ||
|
|
requestUrl.hash
|
|
)
|
|
throw new Error('unsafe collector request URL');
|
|
const started = Date.now();
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 6000);
|
|
const base = {
|
|
schemaVersion: 1,
|
|
upstreamBaseline: UPSTREAM_BASELINE,
|
|
capturedAt: new Date().toISOString().slice(0, 10),
|
|
sourceInstanceAlias: instance.alias,
|
|
operationId: op.operationId,
|
|
method: 'GET',
|
|
pathTemplate: op.pathTemplate,
|
|
redacted: true,
|
|
};
|
|
try {
|
|
validateReadonlyOperation(op);
|
|
const response = await transport(requestUrl, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
body: undefined,
|
|
credentials: 'omit',
|
|
redirect: 'manual',
|
|
signal: controller.signal,
|
|
});
|
|
const contentType =
|
|
(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() || null;
|
|
const claimsJson = contentType === 'application/json' || contentType?.endsWith('+json');
|
|
let parsedJson = false;
|
|
let payload: any;
|
|
if (claimsJson) {
|
|
try {
|
|
payload = await response.json();
|
|
parsedJson = true;
|
|
} catch {
|
|
payload = { error: 'invalid-json' };
|
|
}
|
|
} else {
|
|
await response.text();
|
|
payload = { text: '[REDACTED]' };
|
|
}
|
|
return {
|
|
...base,
|
|
statusCategory: statusCategory(response.status, parsedJson),
|
|
httpStatus: response.status,
|
|
contentType,
|
|
latencyBucket: latency(Date.now() - started),
|
|
payload: redactOperationPayload(payload, op),
|
|
};
|
|
} catch (error: any) {
|
|
if (
|
|
String(error?.message || '').includes('Registry') ||
|
|
String(error?.message || '').includes('operation')
|
|
)
|
|
throw error;
|
|
const timeout = error?.name === 'AbortError';
|
|
return {
|
|
...base,
|
|
statusCategory: timeout ? 'timeout' : 'network-error',
|
|
httpStatus: null,
|
|
contentType: null,
|
|
latencyBucket: latency(Date.now() - started, timeout),
|
|
payload: { error: timeout ? 'timeout' : 'network-error' },
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|