feat(engine): add private NDC nodes and ontology bridge

This commit is contained in:
Codex
2026-07-16 02:23:45 +03:00
parent 569b8762e6
commit 59e9c92415
25 changed files with 10450 additions and 12 deletions
+133
View File
@@ -0,0 +1,133 @@
# n8n-nodes-ndc
Private NODE.DC node package for provider-neutral L2 workflows. It extends n8n
through the supported private-node mechanism and does not patch Engine or n8n
core.
Every custom node has an `NDC ` display-name prefix. Runtime types are package
qualified:
- `n8n-nodes-ndc.ndcDataProductPublish` — publishes canonical facts into an
approved Data Product.
- `n8n-nodes-ndc.ndcDataProductRead` — reads the current snapshot of an
approved Data Product.
- `n8n-nodes-ndc.ndcFoundryBinding` — creates or updates a declarative Foundry
page-slot binding. This is a control-plane operation, not a runtime data
transport.
## Security and scope
Workflow parameters contain no service URL, provider, tenant, connection, raw
secret, or credential selector. Product selection is loaded from the catalog
visible to the selected opaque capability. Service locations are operator-owned
runtime overrides (the package defaults to the canonical internal service
names, so the first deployment needs no extra Engine environment):
- `NDC_DATA_PLANE_BASE_URL`
- `NDC_FOUNDRY_BASE_URL`
The defaults are `http://external-data-plane:18106` and
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
addresses, not workflow configuration.
The three credential types contain only one password-protected opaque
capability. Writer, reader, and Foundry capabilities are intentionally distinct.
Provider identity, product version, ontology revision, persistence policy, and
tenant scope are materialized by the receiving service from the grant. The node
never accepts them from a workflow.
## Fixed routes
- `GET /internal/data-plane/v1/writer/data-products`
- `GET /internal/data-plane/v1/reader/data-products`
- `POST /internal/data-plane/v1/data-products/:dataProductId/publish`
- `GET /internal/data-plane/v1/data-products/:dataProductId/snapshot`
- `GET /internal/foundry/v1/data-products`
- `POST /internal/foundry/v1/data-product-bindings`
There is no fallback to a legacy intake route. `NDC Foundry Binding` uses the
dedicated internal control-plane endpoint implemented by Foundry and fails
closed when that route or its scoped workload grant is unavailable. Its
canonical Map entity-stream slot defaults to `points`.
The binding command uses the versioned
`nodedc.foundry.binding-upsert/v1` schema. Its opaque `ndc_fndbg_*` workload
grant is separate from the short-lived Foundry MCP capability used by AI
Workspace and from the shared Platform service token. The receiving service
materializes actor, owner and exact application/page/binding/product scope from
that grant; none of those authorization claims are accepted from headers or
workflow data.
`NDC Data Product Read` supports only the bounded
`nodedc.data-product.snapshot/v1` contract. Its `Page Size` parameter is a
safety ceiling, not pagination: the complete scoped current projection must fit
within 5000 entity keys. A larger product returns
`data_product_snapshot_limit_exceeded`; the node must not assemble independent
pages or start a patch stream from an incomplete snapshot. Large products need
stable partition Data Products or a separately versioned query contract with a
single snapshot barrier.
## Publish input
Each incoming item must be either a canonical fact or `{ "fact": <fact> }`:
```json
{
"sourceId": "fleet.unit.42",
"semanticType": "map.moving_object",
"observedAt": "2026-07-15T12:00:00.000Z",
"attributes": {},
"geometry": { "type": "Point", "coordinates": [37.61, 55.75] }
}
```
The node emits `nodedc.data-product.publish/v1`, enforces the 5000-fact and
64-KiB-per-attributes limits, rejects secret-like material in attributes, and
derives stable run/idempotency identifiers from workflow execution context.
## Build and verification
```sh
npm install
npm test
npm run lint
```
The package must be installed as an n8n private community package under the
runtime user's community-package root:
```text
/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc
```
The parent `/home/node/.n8n/nodes/package.json` owns the pinned package
registration. `~/.n8n/custom` and `N8N_CUSTOM_EXTENSIONS` are deliberately not
used: n8n loads those through the `CUSTOM` namespace, which would destroy the
required `n8n-nodes-ndc.*` runtime types.
## Deployment boundary
The Platform MCP catalog bridge preserves package-qualified custom runtime
types, but Engine can expose these schemas only after this private package is
installed through the community-package path and the bridge slice is deployed.
Production installation uses a verified offline tarball, an immutable
root-owned release, an atomic current/previous switch and a read-only mount; it
does not run `npm install` inside a live container. This package deliberately
does not patch Engine or n8n core.
The Platform-side release builder is:
```sh
node ../../infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
n8n-nodes-ndc-release-YYYYMMDD-NNN
```
Its canonical deploy component only stages and seals a digest-bound release
under `/volume1/docker/nodedc-platform/n8n-private-extensions`; it does not
activate anything in Engine. Activation is intentionally blocked until an
Engine-owned deployment slice provides the exact read-only community-package
mount, atomic release selection, all-process restart and MCP schema acceptance.
The v2 rollback contract accepts a pre-activation, explicitly verified inactive
baseline for the first activation; subsequent upgrades prefer a previous
verified immutable release. The staged v1 `0.1.0` release is never overwritten:
this contract is published as a separate `0.1.1` release.
@@ -0,0 +1,34 @@
import type {
IAuthenticateGeneric,
Icon,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class NdcDataProductReaderApi implements ICredentialType {
name = 'ndcDataProductReaderApi';
displayName = 'NDC Data Product Reader API';
icon: Icon = {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
};
documentationUrl = '';
properties: INodeProperties[] = [
{
displayName: 'Opaque Reader Capability',
name: 'capability',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.capability}}',
},
},
};
}
@@ -0,0 +1,34 @@
import type {
IAuthenticateGeneric,
Icon,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class NdcDataProductWriterApi implements ICredentialType {
name = 'ndcDataProductWriterApi';
displayName = 'NDC Data Product Writer API';
icon: Icon = {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
};
documentationUrl = '';
properties: INodeProperties[] = [
{
displayName: 'Opaque Writer Capability',
name: 'capability',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.capability}}',
},
},
};
}
@@ -0,0 +1,34 @@
import type {
IAuthenticateGeneric,
Icon,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class NdcFoundryBindingApi implements ICredentialType {
name = 'ndcFoundryBindingApi';
displayName = 'NDC Foundry Binding API';
icon: Icon = {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
};
documentationUrl = '';
properties: INodeProperties[] = [
{
displayName: 'Opaque Binding Capability',
name: 'capability',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.capability}}',
},
},
};
}
+45
View File
@@ -0,0 +1,45 @@
import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';
export default [
...configWithoutCloudSupport,
{
files: ['package.json'],
rules: {
// This is a private, unlicensed Platform extension, not a community package.
'@n8n/community-nodes/valid-author': 'off',
'@n8n/community-nodes/require-mit-license': 'off',
// Keep the tested n8n 2.x runtime boundary instead of the community
// marketplace's mandatory wildcard peer range.
'@n8n/community-nodes/valid-peer-dependencies': 'off',
'n8n-nodes-base/community-package-json-author-missing': 'off',
'n8n-nodes-base/community-package-json-license-not-default': 'off',
},
},
{
files: ['./credentials/**/*.ts'],
rules: {
// Internal capability checks need operator-owned URLs unavailable to a
// static credential test. The nodes test them through scoped catalogs.
'@n8n/community-nodes/credential-test-required': 'off',
'@n8n/community-nodes/credential-documentation-url': 'off',
'n8n-nodes-base/cred-class-field-documentation-url-not-http-url': 'off',
},
},
{
files: ['./nodes/**/*.ts'],
rules: {
// Writes are deliberately fail-closed. continueOnFail would disguise a
// rejected publish/binding as a successful control-plane operation.
'@n8n/community-nodes/require-continue-on-fail': 'off',
// The Engine schema contract exposes exactly three package-qualified
// workflow nodes. Marking them as tools makes n8n 2.3.2 synthesize three
// extra *Tool runtime types and breaks that exact catalog boundary.
'@n8n/community-nodes/node-usable-as-tool': 'off',
// The stock dynamic-options copy links to vendor documentation in the
// product UI. NDC nodes keep that technical runtime brand out of every
// user-visible label and description.
'n8n-nodes-base/node-param-description-missing-from-dynamic-options': 'off',
'n8n-nodes-base/node-param-description-wrong-for-dynamic-options': 'off',
},
},
];
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="NDC">
<g transform="translate(6 23.5) scale(.9)" fill="#a98aff">
<path d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<path d="M31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 362 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="NDC">
<g transform="translate(6 23.5) scale(.9)" fill="#6f50bd">
<path d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<path d="M31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 362 B

@@ -0,0 +1,105 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import {
DATA_PRODUCT_BASE_PATH,
DATA_PLANE_DEFAULT_BASE_URL,
DATA_PLANE_BASE_URL_ENV,
DATA_PRODUCT_WRITER_CATALOG_PATH,
NDC_DATA_PRODUCT_WRITER_CREDENTIAL,
NDC_NODE_ICON,
} from '../shared/constants';
import { buildPublishPayload, encodeIdentifierPath } from '../shared/contracts';
import { safeHttpError, safeInputError } from '../shared/errors';
import { loadDataProductOptions, ndcRequest, serviceBaseUrl } from '../shared/http';
export class NdcDataProductPublish implements INodeType {
description: INodeTypeDescription = {
displayName: 'NDC Data Product Publish',
name: 'ndcDataProductPublish',
icon: NDC_NODE_ICON,
group: ['output'],
version: 1,
subtitle: '={{$parameter["dataProductId"]}}',
description: 'Publish canonical facts through a scoped NDC Data Product grant',
defaults: { name: 'NDC Data Product Publish' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: NDC_DATA_PRODUCT_WRITER_CREDENTIAL, required: true }],
properties: [
{
displayName: 'Data Product Name or ID',
name: 'dataProductId',
type: 'options',
typeOptions: { loadOptionsMethod: 'getDataProducts' },
default: '',
required: true,
},
{
displayName: 'Batch Sequence',
name: 'sequence',
type: 'number',
typeOptions: { minValue: 0, numberStepSize: 1 },
default: 0,
required: true,
description: 'Sequence number when one execution publishes several batches',
},
],
};
methods = {
loadOptions: {
async getDataProducts(this: ILoadOptionsFunctions) {
return loadDataProductOptions(
this,
NDC_DATA_PRODUCT_WRITER_CREDENTIAL,
DATA_PLANE_BASE_URL_ENV,
DATA_PLANE_DEFAULT_BASE_URL,
DATA_PRODUCT_WRITER_CATALOG_PATH,
);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const inputItems = this.getInputData();
const dataProductId = this.getNodeParameter('dataProductId', 0) as string;
const sequence = this.getNodeParameter('sequence', 0, 0) as number;
let body;
let encodedProductId;
try {
body = buildPublishPayload(
inputItems,
this.getExecutionId(),
String(this.getWorkflow().id ?? 'workflow'),
this.getNode().id,
dataProductId,
sequence,
);
encodedProductId = encodeIdentifierPath(dataProductId, 'dataProductId');
} catch (error) {
throw new NodeOperationError(this.getNode(), safeInputError(error, 'ndc_data_product_publish_input_invalid'));
}
try {
const baseUrl = serviceBaseUrl(DATA_PLANE_BASE_URL_ENV, DATA_PLANE_DEFAULT_BASE_URL);
const response = await ndcRequest(this, NDC_DATA_PRODUCT_WRITER_CREDENTIAL, {
method: 'POST',
url: `${baseUrl}${DATA_PRODUCT_BASE_PATH}/${encodedProductId}/publish`,
body,
});
return [[{
json: response as INodeExecutionData['json'],
pairedItem: inputItems.map((_item, index) => ({ item: index })),
}]];
} catch (error) {
throw new NodeOperationError(this.getNode(), safeHttpError(error, 'ndc_data_product_publish_failed'));
}
}
}
@@ -0,0 +1,99 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import {
DATA_PRODUCT_BASE_PATH,
DATA_PRODUCT_READER_CATALOG_PATH,
DATA_PRODUCT_SNAPSHOT_SUFFIX,
DATA_PLANE_DEFAULT_BASE_URL,
DATA_PLANE_BASE_URL_ENV,
NDC_DATA_PRODUCT_READER_CREDENTIAL,
NDC_NODE_ICON,
} from '../shared/constants';
import { normalizeSnapshotFacts, snapshotReadParameters } from '../shared/contracts';
import { safeHttpError, safeInputError } from '../shared/errors';
import { loadDataProductOptions, ndcRequest, serviceBaseUrl } from '../shared/http';
export class NdcDataProductRead implements INodeType {
description: INodeTypeDescription = {
displayName: 'NDC Data Product Read',
name: 'ndcDataProductRead',
icon: NDC_NODE_ICON,
group: ['input'],
version: 1,
subtitle: '={{$parameter["dataProductId"]}}',
description: 'Read a scoped NDC Data Product snapshot',
defaults: { name: 'NDC Data Product Read' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: NDC_DATA_PRODUCT_READER_CREDENTIAL, required: true }],
properties: [
{
displayName: 'Data Product Name or ID',
name: 'dataProductId',
type: 'options',
typeOptions: { loadOptionsMethod: 'getDataProducts' },
default: '',
required: true,
},
{
displayName: 'Page Size',
name: 'pageSize',
type: 'number',
typeOptions: { minValue: 1, maxValue: 5000, numberStepSize: 1 },
default: 1000,
required: true,
description: 'Maximum number of snapshot facts to return',
},
],
};
methods = {
loadOptions: {
async getDataProducts(this: ILoadOptionsFunctions) {
return loadDataProductOptions(
this,
NDC_DATA_PRODUCT_READER_CREDENTIAL,
DATA_PLANE_BASE_URL_ENV,
DATA_PLANE_DEFAULT_BASE_URL,
DATA_PRODUCT_READER_CATALOG_PATH,
);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const dataProductId = this.getNodeParameter('dataProductId', 0) as string;
const pageSize = this.getNodeParameter('pageSize', 0, 1000) as number;
let parameters;
try {
parameters = snapshotReadParameters(dataProductId, pageSize);
} catch (error) {
throw new NodeOperationError(this.getNode(), safeInputError(error, 'ndc_data_product_read_input_invalid'));
}
try {
const baseUrl = serviceBaseUrl(DATA_PLANE_BASE_URL_ENV, DATA_PLANE_DEFAULT_BASE_URL);
const response = await ndcRequest(this, NDC_DATA_PRODUCT_READER_CREDENTIAL, {
method: 'GET',
url: `${baseUrl}${DATA_PRODUCT_BASE_PATH}/${parameters.encodedProductId}${DATA_PRODUCT_SNAPSHOT_SUFFIX}`,
qs: {
limit: parameters.pageSize,
},
});
const facts = normalizeSnapshotFacts(response);
return [facts.map((fact) => ({ json: fact, pairedItem: { item: 0 } }))];
} catch (error) {
const safe = error instanceof Error && error.message === 'data_product_snapshot_invalid'
? safeInputError(error, 'ndc_data_product_snapshot_invalid')
: safeHttpError(error, 'ndc_data_product_read_failed');
throw new NodeOperationError(this.getNode(), safe);
}
}
}
@@ -0,0 +1,140 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import {
FOUNDRY_BINDING_PATH,
FOUNDRY_BASE_URL_ENV,
FOUNDRY_CATALOG_PATH,
FOUNDRY_DEFAULT_BASE_URL,
NDC_FOUNDRY_BINDING_CREDENTIAL,
NDC_NODE_ICON,
} from '../shared/constants';
import { buildFoundryBindingPayload } from '../shared/contracts';
import { safeHttpError, safeInputError } from '../shared/errors';
import { loadDataProductOptions, ndcRequest, serviceBaseUrl } from '../shared/http';
export class NdcFoundryBinding implements INodeType {
description: INodeTypeDescription = {
displayName: 'NDC Foundry Binding',
name: 'ndcFoundryBinding',
icon: NDC_NODE_ICON,
group: ['output'],
version: 1,
subtitle: '={{$parameter["dataProductId"]}}',
description: 'Bind an approved Data Product to a Foundry page slot through the control plane',
defaults: { name: 'NDC Foundry Binding' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: NDC_FOUNDRY_BINDING_CREDENTIAL, required: true }],
properties: [
{
displayName: 'Uses the dedicated Foundry control-plane endpoint and a separate scoped workload grant. It fails closed if either is unavailable and never sends runtime facts or provider credentials to Foundry.',
name: 'availabilityNotice',
type: 'notice',
default: '',
},
{
displayName: 'Application ID',
name: 'applicationId',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Page ID',
name: 'pageId',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Binding ID',
name: 'bindingId',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Data Product Name or ID',
name: 'dataProductId',
type: 'options',
typeOptions: { loadOptionsMethod: 'getDataProducts' },
default: '',
required: true,
},
{
displayName: 'Slot ID',
name: 'slotId',
type: 'string',
default: 'points',
required: true,
},
{
displayName: 'Semantic Types',
name: 'semanticTypes',
type: 'string',
typeOptions: { multipleValues: true, multipleValueButtonText: 'Add Semantic Type' },
default: [],
required: true,
},
{
displayName: 'Field Projection',
name: 'fieldProjection',
type: 'string',
typeOptions: { multipleValues: true, multipleValueButtonText: 'Add Field' },
default: [],
},
],
};
methods = {
loadOptions: {
async getDataProducts(this: ILoadOptionsFunctions) {
return loadDataProductOptions(
this,
NDC_FOUNDRY_BINDING_CREDENTIAL,
FOUNDRY_BASE_URL_ENV,
FOUNDRY_DEFAULT_BASE_URL,
FOUNDRY_CATALOG_PATH,
);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let body;
try {
body = buildFoundryBindingPayload({
applicationId: this.getNodeParameter('applicationId', 0) as string,
pageId: this.getNodeParameter('pageId', 0) as string,
binding: {
id: this.getNodeParameter('bindingId', 0) as string,
dataProductId: this.getNodeParameter('dataProductId', 0) as string,
slotId: this.getNodeParameter('slotId', 0) as string,
semanticTypes: this.getNodeParameter('semanticTypes', 0, []) as string[],
fieldProjection: this.getNodeParameter('fieldProjection', 0, []) as string[],
},
});
} catch (error) {
throw new NodeOperationError(this.getNode(), safeInputError(error, 'ndc_foundry_binding_input_invalid'));
}
try {
const baseUrl = serviceBaseUrl(FOUNDRY_BASE_URL_ENV, FOUNDRY_DEFAULT_BASE_URL);
const response = await ndcRequest(this, NDC_FOUNDRY_BINDING_CREDENTIAL, {
method: 'POST',
url: `${baseUrl}${FOUNDRY_BINDING_PATH}`,
body,
});
return [[{ json: response as INodeExecutionData['json'], pairedItem: { item: 0 } }]];
} catch (error) {
throw new NodeOperationError(this.getNode(), safeHttpError(error, 'ndc_foundry_binding_unavailable'));
}
}
}
@@ -0,0 +1,23 @@
export const DATA_PRODUCT_PUBLISH_SCHEMA_VERSION = 'nodedc.data-product.publish/v1';
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = 'nodedc.foundry.binding-upsert/v1';
export const DATA_PRODUCT_WRITER_CATALOG_PATH = '/internal/data-plane/v1/writer/data-products';
export const DATA_PRODUCT_READER_CATALOG_PATH = '/internal/data-plane/v1/reader/data-products';
export const DATA_PRODUCT_BASE_PATH = '/internal/data-plane/v1/data-products';
export const DATA_PRODUCT_SNAPSHOT_SUFFIX = '/snapshot';
export const FOUNDRY_BINDING_PATH = '/internal/foundry/v1/data-product-bindings';
export const FOUNDRY_CATALOG_PATH = '/internal/foundry/v1/data-products';
export const DATA_PLANE_BASE_URL_ENV = 'NDC_DATA_PLANE_BASE_URL';
export const FOUNDRY_BASE_URL_ENV = 'NDC_FOUNDRY_BASE_URL';
export const DATA_PLANE_DEFAULT_BASE_URL = 'http://external-data-plane:18106';
export const FOUNDRY_DEFAULT_BASE_URL = 'http://nodedc-module-foundry:3333';
export const NDC_DATA_PRODUCT_WRITER_CREDENTIAL = 'ndcDataProductWriterApi';
export const NDC_DATA_PRODUCT_READER_CREDENTIAL = 'ndcDataProductReaderApi';
export const NDC_FOUNDRY_BINDING_CREDENTIAL = 'ndcFoundryBindingApi';
export const NDC_NODE_ICON = {
light: 'file:../../icons/ndc.svg',
dark: 'file:../../icons/ndc.dark.svg',
} as const;
@@ -0,0 +1,238 @@
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?: {
type: 'Point';
coordinates: [number, number];
};
}
export interface NdcPublishPayload extends IDataObject {
schemaVersion: typeof DATA_PRODUCT_PUBLISH_SCHEMA_VERSION;
batch: {
runId: string;
sequence: number;
idempotencyKey: 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[]): NdcFact[] {
if (!items.length) throw new Error('facts_required');
if (items.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
const entityKeys = new Set<string>();
return items.map((item, index) => {
const source = isObject(item.json.fact) ? item.json.fact : item.json;
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 = normalizePoint(source.geometry, index);
return fact;
});
}
export function buildPublishPayload(
items: INodeExecutionData[],
executionId: string,
workflowId: string,
nodeId: string,
dataProductId: string,
sequence: number,
): NdcPublishPayload {
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
requireIdentifier(dataProductId, 'dataProductId');
const runId = `run-${sha256(`${workflowId}|${executionId}`).slice(0, 48)}`;
const idempotencyKey = `publish-${sha256(
`${workflowId}|${executionId}|${nodeId}|${dataProductId}|${sequence}`,
)}`;
return {
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
batch: { runId, sequence, idempotencyKey },
facts: factsFromItems(items),
};
}
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 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 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 normalizePoint(value: unknown, index: number): NdcFact['geometry'] {
if (!isObject(value) || value.type !== 'Point' || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
throw new Error(`facts_${index}_geometry_invalid`);
}
const [longitude, latitude] = value.coordinates;
if (![longitude, latitude].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate))) {
throw new Error(`facts_${index}_geometry_coordinates_invalid`);
}
if ((longitude as number) < -180 || (longitude as number) > 180 || (latitude as number) < -90 || (latitude as number) > 90) {
throw new Error(`facts_${index}_geometry_coordinates_out_of_range`);
}
return { type: 'Point', coordinates: [longitude as number, latitude as number] };
}
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);
}
@@ -0,0 +1,17 @@
export function safeInputError(error: unknown, fallback: string): Error {
const message = error instanceof Error ? error.message : '';
const normalized = /^[a-z0-9_.:-]{3,160}$/i.test(message) ? message : fallback;
return new Error(normalized);
}
export function safeHttpError(error: unknown, fallback: string): Error {
const source = error && typeof error === 'object' ? error as Record<string, unknown> : {};
const response = source.response && typeof source.response === 'object'
? source.response as Record<string, unknown>
: {};
const candidate = Number(source.statusCode ?? source.status ?? response.statusCode ?? response.status);
const suffix = Number.isInteger(candidate) && candidate >= 100 && candidate <= 599
? `_http_${candidate}`
: '';
return new Error(`${fallback}${suffix}`);
}
@@ -0,0 +1,40 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IHttpRequestOptions,
INodeListSearchItems,
} from 'n8n-workflow';
import { dataProductOptions, normalizeBaseUrl } from './contracts';
type NdcHttpContext = IExecuteFunctions | ILoadOptionsFunctions;
export function serviceBaseUrl(environmentVariable: string, defaultBaseUrl: string): string {
return normalizeBaseUrl(process.env[environmentVariable] || defaultBaseUrl);
}
export async function ndcRequest(
context: NdcHttpContext,
credentialName: string,
options: IHttpRequestOptions,
): Promise<unknown> {
return context.helpers.httpRequestWithAuthentication.call(context, credentialName, {
...options,
json: true,
});
}
export async function loadDataProductOptions(
context: ILoadOptionsFunctions,
credentialName: string,
baseUrlEnvironmentVariable: string,
defaultBaseUrl: string,
catalogPath: string,
): Promise<INodeListSearchItems[]> {
const baseUrl = serviceBaseUrl(baseUrlEnvironmentVariable, defaultBaseUrl);
const response = await ndcRequest(context, credentialName, {
method: 'GET',
url: `${baseUrl}${catalogPath}`,
});
return dataProductOptions(response);
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "n8n-nodes-ndc",
"version": "0.1.2",
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
"private": true,
"license": "UNLICENSED",
"keywords": [
"n8n-community-node-package",
"nodedc",
"ndc"
],
"scripts": {
"build": "n8n-node build",
"lint": "n8n-node lint",
"test": "npm run build && node test/package-policy.test.cjs"
},
"files": [
"dist",
"README.md"
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": false,
"credentials": [
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js"
],
"nodes": [
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
"dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "0.39.3",
"n8n-workflow": "2.3.1",
"typescript": "5.9.3"
},
"peerDependencies": {
"n8n-workflow": ">=2.3.1 <3"
}
}
@@ -0,0 +1,347 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const packageRoot = path.resolve(__dirname, '..');
const packageJson = require(path.join(packageRoot, 'package.json'));
const nodeSpecs = [
['NdcDataProductPublish', 'ndcDataProductPublish', 'NDC Data Product Publish'],
['NdcDataProductRead', 'ndcDataProductRead', 'NDC Data Product Read'],
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
];
const credentialSpecs = [
['NdcDataProductWriterApi', 'ndcDataProductWriterApi'],
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'],
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'],
];
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
const forbiddenBindingKey = /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i;
const forbiddenProductBrand = /n8n(?:-nodes-ndc)?/i;
const ndcNodeIcon = {
light: 'file:../../icons/ndc.svg',
dark: 'file:../../icons/ndc.dark.svg',
};
async function main() {
assert.equal(packageJson.name, 'n8n-nodes-ndc');
assert.equal(packageJson.dependencies, undefined, 'private NDC nodes must not add runtime supply-chain dependencies');
for (const lifecycle of ['preinstall', 'install', 'postinstall']) {
assert.equal(packageJson.scripts?.[lifecycle], undefined, `${lifecycle} lifecycle script is forbidden`);
}
assert.equal(packageJson.n8n.nodes.length, nodeSpecs.length, 'every registered custom node must be covered by policy');
assert.deepEqual(
[...packageJson.n8n.nodes].sort(),
nodeSpecs
.map(([className]) => `dist/nodes/${className}/${className}.node.js`)
.sort(),
'registered custom nodes and policy specs must stay in lockstep',
);
const nodes = new Map();
for (const [className, internalName, displayName] of nodeSpecs) {
const modulePath = path.join(packageRoot, 'dist', 'nodes', className, `${className}.node.js`);
const NodeClass = require(modulePath)[className];
const node = new NodeClass();
nodes.set(className, node);
assert.match(node.description.displayName, /^NDC /);
assert.match(node.description.defaults.name, /^NDC /);
assert.equal(node.description.displayName, displayName);
assert.equal(node.description.defaults.name, displayName);
assert.deepEqual(node.description.icon, ndcNodeIcon);
assert.match(node.description.name, /^ndc[A-Z]/);
assert.equal(node.description.name, internalName);
assert.equal(`${packageJson.name}.${node.description.name}`, `${packageJson.name}.${internalName}`);
assert.notEqual(
node.description.usableAsTool,
true,
`${className} must not generate an additional *Tool runtime type`,
);
for (const property of node.description.properties) {
assert.doesNotMatch(property.name, forbiddenNodeParameter, `${className}.${property.name} is transport-specific`);
}
assert.equal(node.description.credentials.length, 1);
assert.match(node.description.credentials[0].name, /^ndc[A-Z]/);
assertProductSurfaceHasNdcBrand(node.description, className);
}
for (const [className, internalName] of credentialSpecs) {
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
const CredentialClass = require(modulePath)[className];
const credential = new CredentialClass();
assert.match(credential.displayName, /^NDC /);
assert.equal(credential.name, internalName);
assert.deepEqual(credential.icon, {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
});
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
assert.equal(credential.properties[0].typeOptions.password, true);
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
assertProductSurfaceHasNdcBrand({
displayName: credential.displayName,
documentationUrl: credential.documentationUrl,
properties: credential.properties,
}, className);
}
for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`);
assert.match(fs.readFileSync(iconPath, 'utf8'), /aria-label="NDC"/, `${icon} must expose the NDC brand`);
}
const contracts = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'contracts.js'));
const constants = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'constants.js'));
const http = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'http.js'));
const externalContract = await import(pathToFileURL(
path.resolve(packageRoot, '..', 'external-provider-contract', 'src', 'index.mjs'),
).href);
const items = [
{
json: {
sourceId: 'fleet.unit.42',
semanticType: 'map.moving_object',
observedAt: '2026-07-15T12:00:00.000Z',
attributes: { speedKph: 12 },
geometry: { type: 'Point', coordinates: [37.61, 55.75] },
},
},
];
const publish = contracts.buildPublishPayload(
items,
'execution-42',
'workflow-7',
'node-3',
'fleet.positions.current.v1',
0,
);
assert.deepEqual(externalContract.validateDataProductPublish(publish), { ok: true, errors: [] });
assert.deepEqual(
publish,
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v1', 0),
'same execution chunk must produce the same idempotency envelope',
);
assert.notEqual(
publish.batch.idempotencyKey,
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v2', 0).batch.idempotencyKey,
);
assert.throws(
() => contracts.buildPublishPayload(
[{ json: { ...items[0].json, attributes: { accessToken: 'forbidden' } } }],
'execution-42',
'workflow-7',
'node-3',
'fleet.positions.current.v1',
0,
),
/secret_material_forbidden/,
);
const foundry = contracts.buildFoundryBindingPayload({
applicationId: '11111111-1111-4111-8111-111111111111',
pageId: 'map',
binding: {
id: 'fleet-live-points',
dataProductId: 'fleet.positions.current.v1',
slotId: 'points',
semanticTypes: ['map.moving_object'],
fieldProjection: ['coordinates.longitude', 'coordinates.latitude'],
},
});
assertNoForbiddenKeys(foundry, forbiddenBindingKey);
assert.equal(externalContract.validateFoundryBindingUpsert(foundry).ok, true);
assert.equal(foundry.schemaVersion, externalContract.FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION);
assert.equal(foundry.binding.dataProductId, 'fleet.positions.current.v1');
assert.equal(foundry.binding.slotId, 'points');
assert.match(foundry.idempotencyKey, /^foundry-binding-[a-f0-9]{32}$/);
assert.equal(constants.DATA_PRODUCT_WRITER_CATALOG_PATH, '/internal/data-plane/v1/writer/data-products');
assert.equal(constants.DATA_PRODUCT_READER_CATALOG_PATH, '/internal/data-plane/v1/reader/data-products');
assert.equal(constants.DATA_PRODUCT_BASE_PATH, '/internal/data-plane/v1/data-products');
assert.equal(constants.DATA_PLANE_DEFAULT_BASE_URL, 'http://external-data-plane:18106');
assert.equal(constants.FOUNDRY_DEFAULT_BASE_URL, 'http://nodedc-module-foundry:3333');
const previousDefaultProbe = process.env.NDC_DATA_PLANE_BASE_URL;
delete process.env.NDC_DATA_PLANE_BASE_URL;
assert.equal(
http.serviceBaseUrl(constants.DATA_PLANE_BASE_URL_ENV, constants.DATA_PLANE_DEFAULT_BASE_URL),
'http://external-data-plane:18106',
);
restoreEnvironment('NDC_DATA_PLANE_BASE_URL', previousDefaultProbe);
const readProperties = nodes.get('NdcDataProductRead').description.properties.map((property) => property.name);
assert.equal(readProperties.includes('cursor'), false, 'snapshot endpoint has no cursor input');
assert.equal(readProperties.includes('history'), false, 'history must stay hidden until its endpoint exists');
const pageSizeProperty = nodes.get('NdcDataProductRead').description.properties.find((property) => property.name === 'pageSize');
assert.equal(pageSizeProperty.typeOptions.minValue, 1);
assert.equal(pageSizeProperty.typeOptions.maxValue, 5000, 'bounded snapshot v1 must not expose more than 5000 facts');
assert.equal(pageSizeProperty.default <= pageSizeProperty.typeOptions.maxValue, true);
const slotProperty = nodes.get('NdcFoundryBinding').description.properties.find((property) => property.name === 'slotId');
assert.equal(slotProperty.default, 'points');
await assertNodeHttpContracts(nodes, externalContract, constants);
console.log('n8n-nodes-ndc package policy: ok');
}
function assertProductSurfaceHasNdcBrand(surface, className) {
const visibleStrings = [];
collectVisibleStrings(surface, visibleStrings);
for (const value of visibleStrings) {
assert.doesNotMatch(value, forbiddenProductBrand, `${className} leaks a technical package/runtime brand: ${value}`);
}
}
function collectVisibleStrings(value, output, key = '') {
if (typeof value === 'string') {
if (!['name', 'type', 'value'].includes(key)) output.push(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectVisibleStrings(item, output, key);
return;
}
if (!value || typeof value !== 'object') return;
for (const [childKey, childValue] of Object.entries(value)) {
if (['credentials', 'icon'].includes(childKey)) continue;
collectVisibleStrings(childValue, output, childKey);
}
}
async function assertNodeHttpContracts(nodes, externalContract, constants) {
const previousDataPlaneUrl = process.env.NDC_DATA_PLANE_BASE_URL;
const previousFoundryUrl = process.env.NDC_FOUNDRY_BASE_URL;
process.env.NDC_DATA_PLANE_BASE_URL = 'http://data-plane.test/';
process.env.NDC_FOUNDRY_BASE_URL = 'http://foundry.test/';
try {
const publishCalls = [];
const publishContext = executionContext({
parameters: { dataProductId: 'fleet.positions.current.v1', sequence: 0 },
input: [{ json: canonicalFact() }],
response: { ok: true, publishedFactCount: 1 },
calls: publishCalls,
});
const publishResult = await nodes.get('NdcDataProductPublish').execute.call(publishContext);
assert.equal(publishCalls[0].credentialName, 'ndcDataProductWriterApi');
assert.equal(publishCalls[0].options.method, 'POST');
assert.equal(
publishCalls[0].options.url,
'http://data-plane.test/internal/data-plane/v1/data-products/fleet.positions.current.v1/publish',
);
assert.equal(externalContract.validateDataProductPublish(publishCalls[0].options.body).ok, true);
assert.equal(publishResult[0][0].json.publishedFactCount, 1);
const readCalls = [];
const readContext = executionContext({
parameters: { dataProductId: 'fleet.positions.current.v1', pageSize: 1000 },
response: {
schemaVersion: 'nodedc.data-product.snapshot/v1',
dataProduct: { id: 'fleet.positions.current.v1', version: '1.0.0' },
generatedAt: '2026-07-15T12:00:01.000Z',
cursor: '1',
facts: [{ ...canonicalFact(), receivedAt: '2026-07-15T12:00:00.100Z' }],
},
calls: readCalls,
});
const readResult = await nodes.get('NdcDataProductRead').execute.call(readContext);
assert.equal(readCalls[0].credentialName, 'ndcDataProductReaderApi');
assert.equal(readCalls[0].options.method, 'GET');
assert.equal(
readCalls[0].options.url,
'http://data-plane.test/internal/data-plane/v1/data-products/fleet.positions.current.v1/snapshot',
);
assert.deepEqual(readCalls[0].options.qs, { limit: 1000 });
assert.equal(readResult[0][0].json.sourceId, 'fleet.unit.42');
const foundryCalls = [];
const foundryContext = executionContext({
parameters: {
applicationId: '11111111-1111-4111-8111-111111111111',
pageId: 'map',
bindingId: 'fleet-live-points',
dataProductId: 'fleet.positions.current.v1',
slotId: 'points',
semanticTypes: ['map.moving_object'],
fieldProjection: ['coordinates.longitude'],
},
response: { ok: true, binding: { id: 'fleet-live-points' } },
calls: foundryCalls,
});
await nodes.get('NdcFoundryBinding').execute.call(foundryContext);
assert.equal(foundryCalls[0].credentialName, 'ndcFoundryBindingApi');
assert.equal(foundryCalls[0].options.method, 'POST');
assert.equal(foundryCalls[0].options.url, 'http://foundry.test/internal/foundry/v1/data-product-bindings');
assertNoForbiddenKeys(foundryCalls[0].options.body, forbiddenBindingKey);
const catalogCalls = [];
const catalogContext = {
helpers: {
async httpRequestWithAuthentication(credentialName, options) {
catalogCalls.push({ credentialName, options });
return { dataProducts: [{ id: 'fleet.positions.current.v1', version: '1.0.0' }] };
},
},
};
const writerOptions = await nodes.get('NdcDataProductPublish').methods.loadOptions.getDataProducts.call(catalogContext);
const readerOptions = await nodes.get('NdcDataProductRead').methods.loadOptions.getDataProducts.call(catalogContext);
assert.deepEqual(writerOptions, [{ name: 'fleet.positions.current.v1 · 1.0.0', value: 'fleet.positions.current.v1' }]);
assert.deepEqual(readerOptions, writerOptions);
assert.equal(catalogCalls[0].options.url, `http://data-plane.test${constants.DATA_PRODUCT_WRITER_CATALOG_PATH}`);
assert.equal(catalogCalls[1].options.url, `http://data-plane.test${constants.DATA_PRODUCT_READER_CATALOG_PATH}`);
} finally {
restoreEnvironment('NDC_DATA_PLANE_BASE_URL', previousDataPlaneUrl);
restoreEnvironment('NDC_FOUNDRY_BASE_URL', previousFoundryUrl);
}
}
function executionContext({ parameters, input = [{ json: {} }], response, calls }) {
return {
getNodeParameter(name, _index, defaultValue) {
return Object.prototype.hasOwnProperty.call(parameters, name) ? parameters[name] : defaultValue;
},
getInputData() { return input; },
getExecutionId() { return 'execution-42'; },
getWorkflow() { return { id: 'workflow-7' }; },
getNode() { return { id: 'node-3' }; },
helpers: {
async httpRequestWithAuthentication(credentialName, options) {
calls.push({ credentialName, options });
return response;
},
},
};
}
function canonicalFact() {
return {
sourceId: 'fleet.unit.42',
semanticType: 'map.moving_object',
observedAt: '2026-07-15T12:00:00.000Z',
attributes: { speedKph: 12 },
geometry: { type: 'Point', coordinates: [37.61, 55.75] },
};
}
function restoreEnvironment(name, value) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function assertNoForbiddenKeys(value, pattern, pathPrefix = 'body') {
if (Array.isArray(value)) {
value.forEach((entry, index) => assertNoForbiddenKeys(entry, pattern, `${pathPrefix}[${index}]`));
return;
}
if (!value || typeof value !== 'object') return;
for (const [key, child] of Object.entries(value)) {
assert.doesNotMatch(key, pattern, `${pathPrefix}.${key} contains transport material`);
assertNoForbiddenKeys(child, pattern, `${pathPrefix}.${key}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": false,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": [
"credentials/**/*",
"nodes/**/*",
"package.json"
]
}