396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
import type { IDataObject, INodeExecutionData, INodeListSearchItems } from 'n8n-workflow';
|
|
|
|
import {
|
|
DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
|
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
|
} from './constants';
|
|
|
|
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
|
const MAX_FACTS = 5000;
|
|
const MAX_ATTRIBUTES_BYTES = 64 * 1024;
|
|
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
|
|
const SECRET_LIKE_VALUE = /(?:ndc_edp(?:wb|rb)_[A-Za-z0-9_-]*|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
|
|
|
export interface NdcFact extends IDataObject {
|
|
sourceId: string;
|
|
semanticType: string;
|
|
observedAt: string;
|
|
attributes?: IDataObject;
|
|
geometry?: NdcGeometry;
|
|
}
|
|
|
|
export type NdcGeometry =
|
|
| { type: 'Point'; coordinates: [number, number] }
|
|
| { type: 'LineString'; coordinates: [number, number][] }
|
|
| { type: 'Polygon'; coordinates: [number, number][][] }
|
|
| { type: 'MultiPolygon'; coordinates: [number, number][][][] };
|
|
|
|
export interface NdcPublishPayload extends IDataObject {
|
|
schemaVersion: typeof DATA_PRODUCT_PUBLISH_SCHEMA_VERSION;
|
|
batch: {
|
|
runId: string;
|
|
sequence: number;
|
|
idempotencyKey: string;
|
|
mode?: 'replace';
|
|
generationAt?: string;
|
|
};
|
|
facts: NdcFact[];
|
|
}
|
|
|
|
export interface NdcFoundryBindingInput extends IDataObject {
|
|
applicationId: string;
|
|
pageId: string;
|
|
binding: {
|
|
id: string;
|
|
dataProductId: string;
|
|
slotId: string;
|
|
semanticTypes: string[];
|
|
fieldProjection: string[];
|
|
};
|
|
}
|
|
|
|
export function normalizeBaseUrl(value: unknown): string {
|
|
const normalized = String(value ?? '').trim().replace(/\/+$/, '');
|
|
if (!/^https?:\/\/[^\s]+$/i.test(normalized)) throw new Error('ndc_internal_api_base_url_invalid');
|
|
return normalized;
|
|
}
|
|
|
|
export function requireIdentifier(value: unknown, field: string): string {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!IDENTIFIER.test(normalized)) throw new Error(`${field}_invalid`);
|
|
return normalized;
|
|
}
|
|
|
|
export function encodeIdentifierPath(value: unknown, field: string): string {
|
|
return encodeURIComponent(requireIdentifier(value, field));
|
|
}
|
|
|
|
export function factsFromItems(items: INodeExecutionData[], allowEmpty = false): NdcFact[] {
|
|
const batchFacts = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
|
? items[0].json.facts
|
|
: null;
|
|
const sources = batchFacts ?? items.map((item) => (isObject(item.json.fact) ? item.json.fact : item.json));
|
|
if (!sources.length && !allowEmpty) throw new Error('facts_required');
|
|
if (sources.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
|
|
const entityKeys = new Set<string>();
|
|
return sources.map((source, index) => {
|
|
if (!isObject(source)) throw new Error(`facts_${index}_invalid`);
|
|
const fact: NdcFact = {
|
|
sourceId: requireIdentifier(source.sourceId, `facts_${index}_sourceId`),
|
|
semanticType: requireIdentifier(source.semanticType, `facts_${index}_semanticType`),
|
|
observedAt: requireIsoTimestamp(source.observedAt, `facts_${index}_observedAt`),
|
|
};
|
|
const entityKey = `${fact.sourceId}\u0000${fact.semanticType}`;
|
|
if (entityKeys.has(entityKey)) throw new Error('facts_duplicate_entity_key');
|
|
entityKeys.add(entityKey);
|
|
if (source.attributes !== undefined) {
|
|
if (!isObject(source.attributes)) throw new Error(`facts_${index}_attributes_invalid`);
|
|
if (Buffer.byteLength(JSON.stringify(source.attributes)) > MAX_ATTRIBUTES_BYTES) {
|
|
throw new Error(`facts_${index}_attributes_size_exceeded`);
|
|
}
|
|
if (containsSecretLikeMaterial(source.attributes)) throw new Error(`facts_${index}_attributes_secret_material_forbidden`);
|
|
fact.attributes = source.attributes;
|
|
}
|
|
if (source.geometry !== undefined) fact.geometry = normalizeGeometry(source.geometry, index);
|
|
return fact;
|
|
});
|
|
}
|
|
|
|
export function buildPublishPayload(
|
|
items: INodeExecutionData[],
|
|
executionId: string,
|
|
workflowId: string,
|
|
nodeId: string,
|
|
dataProductId: string,
|
|
sequence: number,
|
|
publishMode: 'upsert' | 'replace' = 'upsert',
|
|
): NdcPublishPayload {
|
|
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
|
|
requireIdentifier(dataProductId, 'dataProductId');
|
|
const facts = factsFromItems(items, publishMode === 'replace');
|
|
const explicitGenerationAt = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
|
? items[0].json.generationAt
|
|
: undefined;
|
|
const generationAt = publishMode === 'replace'
|
|
? replacementGenerationAt(facts, explicitGenerationAt)
|
|
: undefined;
|
|
const replacementDigest = generationAt
|
|
? replacementReplayDigest(dataProductId, sequence, generationAt, facts)
|
|
: undefined;
|
|
const runId = replacementDigest
|
|
? `run-${replacementDigest.slice(0, 48)}`
|
|
: `run-${sha256(`${workflowId}|${executionId}`).slice(0, 48)}`;
|
|
const idempotencyKey = replacementDigest
|
|
? `publish-${replacementDigest}`
|
|
: `publish-${sha256(`${workflowId}|${executionId}|${nodeId}|${dataProductId}|${sequence}`)}`;
|
|
return {
|
|
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
|
batch: {
|
|
runId,
|
|
sequence,
|
|
idempotencyKey,
|
|
...(generationAt ? { mode: 'replace' as const, generationAt } : {}),
|
|
},
|
|
facts,
|
|
};
|
|
}
|
|
|
|
export function normalizeSnapshotFacts(value: unknown): IDataObject[] {
|
|
if (!isObject(value) || !Array.isArray(value.facts)) throw new Error('data_product_snapshot_invalid');
|
|
return value.facts.map((fact, index) => {
|
|
if (!isObject(fact)) throw new Error(`snapshot_fact_${index}_invalid`);
|
|
return fact;
|
|
});
|
|
}
|
|
|
|
export function normalizeHistoryResponse(value: unknown): IDataObject {
|
|
if (!isObject(value) || value.schemaVersion !== 'nodedc.data-product.history/v1'
|
|
|| !isObject(value.dataProduct) || !isObject(value.query) || !Array.isArray(value.facts)) {
|
|
throw new Error('data_product_history_invalid');
|
|
}
|
|
value.facts.forEach((fact, index) => {
|
|
if (!isObject(fact) || Number.isNaN(Date.parse(String(fact.bucketStart ?? '')))) {
|
|
throw new Error(`history_fact_${index}_invalid`);
|
|
}
|
|
});
|
|
return value;
|
|
}
|
|
|
|
export function snapshotReadParameters(
|
|
dataProductId: unknown,
|
|
pageSize: unknown,
|
|
): { encodedProductId: string; pageSize: number } {
|
|
const normalizedPageSize = Number(pageSize);
|
|
if (!Number.isInteger(normalizedPageSize) || normalizedPageSize < 1 || normalizedPageSize > 5000) {
|
|
throw new Error('page_size_invalid');
|
|
}
|
|
return {
|
|
encodedProductId: encodeIdentifierPath(dataProductId, 'dataProductId'),
|
|
pageSize: normalizedPageSize,
|
|
};
|
|
}
|
|
|
|
export function historyReadParameters(value: {
|
|
dataProductId: unknown;
|
|
from: unknown;
|
|
to: unknown;
|
|
resolutionMs: unknown;
|
|
sourceIds: unknown;
|
|
limit: unknown;
|
|
cursor: unknown;
|
|
}): {
|
|
encodedProductId: string;
|
|
from: string;
|
|
to: string;
|
|
resolutionMs: number;
|
|
sourceIds: string[];
|
|
limit: number;
|
|
cursor: string;
|
|
} {
|
|
const from = requireIsoTimestamp(value.from, 'from');
|
|
const to = requireIsoTimestamp(value.to, 'to');
|
|
if (Date.parse(from) >= Date.parse(to)) throw new Error('history_range_invalid');
|
|
const resolutionMs = Number(value.resolutionMs);
|
|
if (!Number.isInteger(resolutionMs) || resolutionMs < 1000 || resolutionMs > 24 * 60 * 60 * 1000) {
|
|
throw new Error('history_resolution_invalid');
|
|
}
|
|
const limit = Number(value.limit);
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 5000) throw new Error('history_limit_invalid');
|
|
const sourceIds = [...new Set((Array.isArray(value.sourceIds)
|
|
? value.sourceIds
|
|
: String(value.sourceIds ?? '').split(','))
|
|
.map((item) => String(item ?? '').trim())
|
|
.filter(Boolean)
|
|
.map((item) => requireIdentifier(item, 'sourceIds')))];
|
|
if (sourceIds.length > 1000) throw new Error('history_source_ids_limit_exceeded');
|
|
const cursor = String(value.cursor ?? '').trim();
|
|
if (cursor && (cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(cursor))) throw new Error('history_cursor_invalid');
|
|
return {
|
|
encodedProductId: encodeIdentifierPath(value.dataProductId, 'dataProductId'),
|
|
from,
|
|
to,
|
|
resolutionMs,
|
|
sourceIds,
|
|
limit,
|
|
cursor,
|
|
};
|
|
}
|
|
|
|
export function dataProductOptions(value: unknown): INodeListSearchItems[] {
|
|
if (!isObject(value)) throw new Error('data_product_catalog_invalid');
|
|
const rows = Array.isArray(value.dataProducts)
|
|
? value.dataProducts
|
|
: Array.isArray(value.products)
|
|
? value.products
|
|
: [];
|
|
return rows.flatMap((row) => {
|
|
if (typeof row === 'string') {
|
|
return IDENTIFIER.test(row) ? [{ name: row, value: row }] : [];
|
|
}
|
|
if (!isObject(row)) return [];
|
|
const id = String(row.id ?? row.dataProductId ?? '').trim();
|
|
if (!IDENTIFIER.test(id)) return [];
|
|
const version = String(row.version ?? '').trim();
|
|
const label = String(row.label ?? row.name ?? id).trim() || id;
|
|
return [{ name: version ? `${label} · ${version}` : label, value: id }];
|
|
});
|
|
}
|
|
|
|
export function buildFoundryBindingPayload(input: NdcFoundryBindingInput): IDataObject {
|
|
const applicationId = requireFoundryApplicationId(input.applicationId);
|
|
const pageId = requireFoundryPageId(input.pageId);
|
|
const bindingId = requireIdentifier(input.binding.id, 'binding_id');
|
|
const dataProductId = requireIdentifier(input.binding.dataProductId, 'dataProductId');
|
|
const slotId = requireFoundrySlotId(input.binding.slotId);
|
|
const semanticTypes = uniqueIdentifiers(input.binding.semanticTypes, 'semanticTypes');
|
|
if (!semanticTypes.length) throw new Error('semanticTypes_required');
|
|
const fieldProjection = uniqueIdentifiers(input.binding.fieldProjection, 'fieldProjection', true);
|
|
const binding = { id: bindingId, dataProductId, slotId, semanticTypes, fieldProjection };
|
|
const digest = createHash('sha256')
|
|
.update(JSON.stringify({ applicationId, pageId, binding }))
|
|
.digest('hex')
|
|
.slice(0, 32);
|
|
return {
|
|
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
|
applicationId,
|
|
pageId,
|
|
idempotencyKey: `foundry-binding-${digest}`,
|
|
binding,
|
|
};
|
|
}
|
|
|
|
function requireFoundryApplicationId(value: unknown): string {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)) {
|
|
throw new Error('applicationId_invalid');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function requireFoundryPageId(value: unknown): string {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(normalized)) throw new Error('pageId_invalid');
|
|
return normalized;
|
|
}
|
|
|
|
function requireFoundrySlotId(value: unknown): string {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/.test(normalized)) throw new Error('slotId_invalid');
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeGeometry(value: unknown, index: number): NdcGeometry {
|
|
if (!isObject(value) || !['Point', 'LineString', 'Polygon', 'MultiPolygon'].includes(String(value.type))
|
|
|| !Array.isArray(value.coordinates)) {
|
|
throw new Error(`facts_${index}_geometry_invalid`);
|
|
}
|
|
if (Buffer.byteLength(JSON.stringify(value)) > 192 * 1024) throw new Error(`facts_${index}_geometry_size_exceeded`);
|
|
let vertices = 0;
|
|
const position = (candidate: unknown, path: string): [number, number] => {
|
|
if (!Array.isArray(candidate) || candidate.length !== 2 || !candidate.every(Number.isFinite)) {
|
|
throw new Error(`${path}_position_invalid`);
|
|
}
|
|
const [longitude, latitude] = candidate as [number, number];
|
|
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
|
throw new Error(`${path}_position_out_of_range`);
|
|
}
|
|
vertices += 1;
|
|
if (vertices > 10_000) throw new Error(`facts_${index}_geometry_vertex_limit_exceeded`);
|
|
return [longitude, latitude];
|
|
};
|
|
const line = (candidate: unknown, path: string, minimum: number): [number, number][] => {
|
|
if (!Array.isArray(candidate) || candidate.length < minimum) throw new Error(`${path}_line_invalid`);
|
|
return candidate.map((entry, positionIndex) => position(entry, `${path}_${positionIndex}`));
|
|
};
|
|
const ring = (candidate: unknown, path: string): [number, number][] => {
|
|
const result = line(candidate, path, 4);
|
|
const first = result[0];
|
|
const last = result.at(-1);
|
|
if (!last || first[0] !== last[0] || first[1] !== last[1]) throw new Error(`${path}_ring_not_closed`);
|
|
return result;
|
|
};
|
|
const polygon = (candidate: unknown, path: string): [number, number][][] => {
|
|
if (!Array.isArray(candidate) || !candidate.length) throw new Error(`${path}_polygon_invalid`);
|
|
return candidate.map((entry, ringIndex) => ring(entry, `${path}_${ringIndex}`));
|
|
};
|
|
if (value.type === 'Point') return { type: 'Point', coordinates: position(value.coordinates, `facts_${index}_geometry`) };
|
|
if (value.type === 'LineString') return { type: 'LineString', coordinates: line(value.coordinates, `facts_${index}_geometry`, 2) };
|
|
if (value.type === 'Polygon') return { type: 'Polygon', coordinates: polygon(value.coordinates, `facts_${index}_geometry`) };
|
|
const coordinates = value.coordinates.map((entry, polygonIndex) => polygon(entry, `facts_${index}_geometry_${polygonIndex}`));
|
|
if (!coordinates.length) throw new Error(`facts_${index}_geometry_multipolygon_invalid`);
|
|
return { type: 'MultiPolygon', coordinates };
|
|
}
|
|
|
|
function replacementGenerationAt(facts: NdcFact[], explicit: unknown): string {
|
|
const declared = explicit === undefined ? undefined : requireIsoTimestamp(explicit, 'replace_generationAt');
|
|
const timestamps = [...new Set(facts.map((fact) => new Date(fact.observedAt).toISOString()))];
|
|
if (!timestamps.length) {
|
|
if (!declared) throw new Error('replace_generationAt_required_for_empty_generation');
|
|
return new Date(declared).toISOString();
|
|
}
|
|
if (timestamps.length !== 1) throw new Error('replace_generation_requires_one_observedAt');
|
|
if (declared && new Date(declared).toISOString() !== timestamps[0]) {
|
|
throw new Error('replace_generationAt_observedAt_mismatch');
|
|
}
|
|
return timestamps[0];
|
|
}
|
|
|
|
function replacementReplayDigest(
|
|
dataProductId: string,
|
|
sequence: number,
|
|
generationAt: string,
|
|
facts: NdcFact[],
|
|
): string {
|
|
const canonicalFacts = [...facts].sort((left, right) => {
|
|
const leftKey = `${left.sourceId}\u0000${left.semanticType}`;
|
|
const rightKey = `${right.sourceId}\u0000${right.semanticType}`;
|
|
return leftKey.localeCompare(rightKey);
|
|
});
|
|
return sha256(stableJson({
|
|
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
|
dataProductId,
|
|
mode: 'replace',
|
|
generationAt,
|
|
sequence,
|
|
facts: canonicalFacts,
|
|
}));
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
if (isObject(value)) {
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function requireIsoTimestamp(value: unknown, field: string): string {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!normalized || Number.isNaN(Date.parse(normalized))) throw new Error(`${field}_invalid`);
|
|
return normalized;
|
|
}
|
|
|
|
function uniqueIdentifiers(value: unknown, field: string, allowEmpty = false): string[] {
|
|
if (!Array.isArray(value)) throw new Error(`${field}_invalid`);
|
|
const normalized = [...new Set(value.map((item) => requireIdentifier(item, field)))];
|
|
if (!allowEmpty && !normalized.length) throw new Error(`${field}_required`);
|
|
return normalized;
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function containsSecretLikeMaterial(value: unknown): boolean {
|
|
if (typeof value === 'string') return SECRET_LIKE_VALUE.test(value);
|
|
if (Array.isArray(value)) return value.some(containsSecretLikeMaterial);
|
|
if (!isObject(value)) return false;
|
|
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child));
|
|
}
|
|
|
|
function isObject(value: unknown): value is IDataObject {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|