feat(engine): add private NDC nodes and ontology bridge
This commit is contained in:
@@ -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}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
Generated
+8674
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user