feat(engine): add private NDC nodes and ontology bridge
This commit is contained in:
parent
569b8762e6
commit
59e9c92415
|
|
@ -0,0 +1,76 @@
|
|||
# AI Workspace ↔ Ontology Core MCP
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the read-only semantic path for a NODE.DC AI Workspace run:
|
||||
|
||||
```text
|
||||
Codex worker
|
||||
-> dynamic per-run MCP configuration
|
||||
-> public AI Workspace Hub (pairing-bound route)
|
||||
-> internal Ontology Core MCP
|
||||
-> freshly loaded ontology catalog and domain packages
|
||||
```
|
||||
|
||||
`ai-workspace-assistant` creates this MCP server entry at run-profile time for every Hub-connected executor when `AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true`. It is a platform runtime grant (`ontology:catalog:read`), not a user-owned installer secret and not a hard-coded copy of ontology rules in a prompt.
|
||||
|
||||
The worker receives only a pairing-bound Hub URL. The Hub verifies that the pairing agent is online and replaces any external authorization with the platform-internal token before forwarding to `ontology-core`. Ontology Core is internal-only and has no host port or reverse-proxy route.
|
||||
|
||||
## Read-only MCP surface
|
||||
|
||||
`nodedc_ontology` exposes only these tools:
|
||||
|
||||
- `ontology_status` — catalog counts, domain packages and a safe catalog hash;
|
||||
- `ontology_search` — canonical entities, relations and aliases;
|
||||
- `ontology_get_entity` — entity definition, aliases, relations and guardrails;
|
||||
- `ontology_get_guardrails` — semantic/safety rules and blocked conflations;
|
||||
- `ontology_resolve_context` — semantic route advice with source identifiers removed.
|
||||
|
||||
It intentionally does not expose filesystem paths, evidence ledgers, credentials, raw payloads, live telemetry, databases, command dispatch, workflow mutation or Studio controls.
|
||||
|
||||
## Boundary for Gelios and future data services
|
||||
|
||||
Ontology Core describes the canonical meanings and constraints:
|
||||
|
||||
```text
|
||||
gelios.unit -> gelios.telemetry_snapshot -> gelios.position_fix -> map.moving_object
|
||||
```
|
||||
|
||||
It does **not** serve the Gelios database or provider API. The future Gelios Gateway/Data API is a distinct capability with its own scope checks, data contract and transport. That capability may later be supplied to an AI Workspace run as another dynamic MCP server. Ontology then gives the assistant the names, relations, guardrails and allowed data-contract route; the data capability enforces access and returns live data.
|
||||
|
||||
This preserves one-way responsibility:
|
||||
|
||||
- Ontology Core: semantics, contracts, aliases, guardrails and context advice.
|
||||
- Gelios Gateway/Data API: access-scoped telemetry and history reads.
|
||||
- Command Gateway: separate red-domain command route, explicit confirmation and audit.
|
||||
- Engine L2: workflow orchestration using granted capabilities.
|
||||
- Studio: later presentation consumer, outside this implementation.
|
||||
|
||||
## Live catalog updates without AI Workspace rule redeploy
|
||||
|
||||
`loadCatalog()` runs for each Ontology MCP tool call; it does not cache the merged catalog. Therefore a new or edited domain package is visible to existing AI Workspace rules as soon as the catalog content is updated in the running Ontology Core container or mounted runtime catalog directory.
|
||||
|
||||
For a catalog-only release, update/restart **Ontology Core only**. The AI Workspace Assistant, Hub and Codex worker configuration do not need a rules redeploy because the MCP tool names and route stay stable. `NODEDC_ONTOLOGY_CATALOG_ROOT` may point Ontology Core at a separately managed catalog directory when a runtime-mounted catalog is required.
|
||||
|
||||
A Hub/Assistant rollout is needed only if the MCP transport, authorization contract or tool definitions themselves change.
|
||||
|
||||
## Safety properties
|
||||
|
||||
- The MCP server accepts only internal bearer authentication from AI Hub.
|
||||
- A browser `Origin` is rejected by default; allow specific origins only through `ONTOLOGY_MCP_ALLOWED_ORIGINS` if an intentional browser transport is introduced.
|
||||
- The Hub does not pass browser cookies or external `Authorization` downstream.
|
||||
- The Hub route is pairing-bound and is unavailable when the worker is offline.
|
||||
- All current tools are read-only. There is no generic ontology write API.
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
cd platform/services/ontology-core
|
||||
npm run validate
|
||||
npm run smoke:mcp
|
||||
|
||||
cd ../../
|
||||
node --check services/ai-workspace-assistant/src/server.mjs
|
||||
node --check services/ai-workspace-hub/src/server.mjs
|
||||
docker compose --env-file infra/.env -f infra/docker-compose.dev.yml config
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||
|
|
@ -467,9 +467,27 @@ function normalizeTypeName(value) {
|
|||
function fullNodeType(value) {
|
||||
const raw = cleanString(value, 240)
|
||||
if (!raw) return ''
|
||||
if (isPackageQualifiedNodeType(raw)) return raw
|
||||
return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}`
|
||||
}
|
||||
|
||||
function isPackageQualifiedNodeType(value) {
|
||||
return /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+\.[a-z0-9._-]+$/i.test(cleanString(value, 240))
|
||||
}
|
||||
|
||||
function catalogNodeRuntimeType(node) {
|
||||
const explicit = [node?.runtimeType, node?.fullType, node?.publicType, node?.type]
|
||||
.map((value) => cleanString(value, 240))
|
||||
.find(isPackageQualifiedNodeType)
|
||||
if (explicit) return explicit
|
||||
const packageName = cleanString(node?.packageName || node?.package, 160)
|
||||
const name = cleanString(node?.name, 160)
|
||||
if (name && /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+$/i.test(packageName)) {
|
||||
return `${packageName}.${name}`
|
||||
}
|
||||
return fullNodeType(node?.publicType || name)
|
||||
}
|
||||
|
||||
function cleanWebhookSegment(value, fallback) {
|
||||
const out = cleanString(value, 240)
|
||||
.toLowerCase()
|
||||
|
|
@ -538,7 +556,8 @@ function compactNodeDefinition(node, maxProperties = 80) {
|
|||
const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : []
|
||||
return {
|
||||
name: node?.name || '',
|
||||
fullType: fullNodeType(node?.name || ''),
|
||||
publicType: node?.publicType || node?.name || '',
|
||||
fullType: catalogNodeRuntimeType(node),
|
||||
displayName: node?.displayName || node?.name || '',
|
||||
description: node?.description || '',
|
||||
group: node?.group || [],
|
||||
|
|
@ -722,7 +741,8 @@ async function handleSearchNodes(args) {
|
|||
.slice(0, limit)
|
||||
.map(({ node }) => ({
|
||||
name: node?.name || '',
|
||||
fullType: fullNodeType(node?.name || ''),
|
||||
publicType: node?.publicType || node?.name || '',
|
||||
fullType: catalogNodeRuntimeType(node),
|
||||
displayName: node?.displayName || node?.name || '',
|
||||
description: node?.description || '',
|
||||
group: node?.group || [],
|
||||
|
|
@ -737,7 +757,8 @@ async function handleGetNodeDefinition(args) {
|
|||
const catalog = await loadNodeCatalog(args.schemaVersion)
|
||||
const node = catalog.find((item) => (
|
||||
String(item?.name || '') === nodeType ||
|
||||
fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name)
|
||||
String(item?.publicType || '') === nodeType ||
|
||||
catalogNodeRuntimeType(item) === cleanString(args.nodeType || args.type || args.name)
|
||||
))
|
||||
if (!node) throw new Error(`node_not_found:${nodeType}`)
|
||||
return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) }
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]);
|
|||
const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]);
|
||||
const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]);
|
||||
const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
|
||||
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
||||
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "ontology", "deploy", "docs"]);
|
||||
const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
||||
const AI_WORKSPACE_BRIDGE_PACKAGE_NAME = "@nodedc/ai-workspace-bridge";
|
||||
const AI_WORKSPACE_BRIDGE_PACKAGE_BIN = "ai-workspace-bridge";
|
||||
|
|
@ -81,6 +81,26 @@ const APP_ROUTING_CATALOG = [
|
|||
requiredScopes: ["engine:workspace:read"],
|
||||
deniedText: ACCESS_DENIED_TEXT,
|
||||
},
|
||||
{
|
||||
appId: "ontology",
|
||||
appTitle: "NODE.DC Ontology Core",
|
||||
surface: "global",
|
||||
skillId: "ontology-context",
|
||||
whenToUse: [
|
||||
"canonical entities",
|
||||
"aliases",
|
||||
"relations",
|
||||
"semantic guardrails",
|
||||
"data contracts",
|
||||
"cross-contour context",
|
||||
"Gelios domain model",
|
||||
],
|
||||
actionNamespaces: [],
|
||||
actionIdPrefixes: [],
|
||||
mcpServerNames: ["nodedc_ontology"],
|
||||
requiredScopes: ["ontology:catalog:read"],
|
||||
deniedText: ACCESS_DENIED_TEXT,
|
||||
},
|
||||
{
|
||||
appId: "ops",
|
||||
appTitle: "NODE.DC Ops / Tasker",
|
||||
|
|
@ -2113,18 +2133,23 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
|||
|| thread.originSurface
|
||||
|| "global";
|
||||
const targetContexts = isPlainObject(context.contexts) ? context.contexts : {};
|
||||
const enabledToolPacks = mergeToolPacks(
|
||||
let enabledToolPacks = mergeToolPacks(
|
||||
ownerSettings?.enabledToolPacks,
|
||||
thread.enabledToolPacks,
|
||||
bridgePayload?.enabledToolPacks
|
||||
);
|
||||
const grantResolution = await resolveRunAppGrants({ owner, context, ownerSettings });
|
||||
const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants });
|
||||
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
|
||||
const ontologyGrant = ontologyMcpGrantForExecutor(executor);
|
||||
const resolvedAppGrants = ontologyGrant
|
||||
? { ...grantResolution.appGrants, ontology: ontologyGrant }
|
||||
: grantResolution.appGrants;
|
||||
if (ontologyGrant) enabledToolPacks = mergeToolPacks(enabledToolPacks, ["ontology"]);
|
||||
const appGrants = summarizeRunAppGrants({ appGrants: resolvedAppGrants });
|
||||
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, resolvedAppGrants);
|
||||
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
|
||||
const assistantActions = await assistantActionToolProfileForRun();
|
||||
const appCatalog = buildRunProfileAppCatalog({
|
||||
appGrants: grantResolution.appGrants,
|
||||
appGrants: resolvedAppGrants,
|
||||
mcpServers,
|
||||
assistantActions,
|
||||
});
|
||||
|
|
@ -2148,6 +2173,7 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
|||
deniedAppIds: appAccess.deniedAppIds,
|
||||
notGrantedAppIds: appAccess.notGrantedAppIds,
|
||||
entitlementAdapters: grantResolution.diagnostics,
|
||||
ontologyMcp: ontologyMcpDiagnostic(executor, ontologyGrant),
|
||||
mcpServerNames,
|
||||
requiredMcpServerNames,
|
||||
assistantActionIds: assistantActions.actionIds,
|
||||
|
|
@ -2189,6 +2215,41 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
|||
return runProfile;
|
||||
}
|
||||
|
||||
function ontologyMcpGrantForExecutor(executor) {
|
||||
if (!config.ontologyMcpEnabled) return null;
|
||||
if (executor?.connectionMode !== "hub") return null;
|
||||
const pairingCode = cleanPairingCode(executor?.pairingCode);
|
||||
if (!pairingCode || !config.ontologyMcpPublicBaseUrl) return null;
|
||||
return {
|
||||
appId: "ontology",
|
||||
appTitle: "NODE.DC Ontology Core",
|
||||
surface: "global",
|
||||
source: "platform-runtime",
|
||||
status: "granted",
|
||||
scopes: ["ontology:catalog:read"],
|
||||
mcpServers: [{
|
||||
appId: "ontology",
|
||||
appTitle: "NODE.DC Ontology Core",
|
||||
serverName: "nodedc_ontology",
|
||||
url: `${config.ontologyMcpPublicBaseUrl}/api/ai-workspace/hub/v1/ontology-mcp/${encodeURIComponent(pairingCode)}/mcp`,
|
||||
required: false,
|
||||
startupTimeoutSec: 20,
|
||||
toolTimeoutSec: 60,
|
||||
httpHeaders: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": "2025-06-18",
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function ontologyMcpDiagnostic(executor, grant) {
|
||||
if (grant) return { status: "granted", serverName: "nodedc_ontology", readOnly: true };
|
||||
if (!config.ontologyMcpEnabled) return { status: "disabled", readOnly: true };
|
||||
if (executor?.connectionMode !== "hub") return { status: "unavailable_for_direct_executor", readOnly: true };
|
||||
return { status: "pairing_or_public_hub_url_required", readOnly: true };
|
||||
}
|
||||
|
||||
async function assistantActionToolProfileForRun() {
|
||||
const gatewayUrl = assistantActionGatewayUrlForRun();
|
||||
const gatewayToken = optionalString(
|
||||
|
|
@ -2711,6 +2772,10 @@ function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions })
|
|||
"- Interpret the user's natural-language request first; call assistant actions only after selecting a structured action id.",
|
||||
"- Read assistant actions may execute after structured action selection. Privileged/write assistant actions require preview, explicit user confirmation, then execute.",
|
||||
"- Ops card actions advertised in this run are valid assistant actions: use ops.card.list_recent for reading cards, ops.card.create for creating cards, and ops.card.add_comment for comments instead of refusing because direct Ops MCP tools are absent.",
|
||||
...(diagnostics.mcpServerNames.includes("nodedc_ontology") ? [
|
||||
"- The nodedc_ontology MCP server is the live, read-only semantic source for canonical entities, aliases, relations and guardrails. Use it before inventing workflow names, data contracts or cross-contour bindings.",
|
||||
"- Ontology Core does not expose telemetry, databases, credentials, command dispatch or Studio controls. Request those only through separately granted application capabilities.",
|
||||
] : []),
|
||||
"- Destructive assistant actions are forbidden; offer safe alternatives such as block/disable instead of delete.",
|
||||
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
|
||||
];
|
||||
|
|
@ -4465,6 +4530,11 @@ function readConfig() {
|
|||
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||
"";
|
||||
const normalizedSetupGatewayUrl = setupGatewayUrl.replace(/\/+$/, "");
|
||||
const ontologyMcpPublicBaseUrl = cleanHttpEndpoint(
|
||||
optionalString(process.env.AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL) ||
|
||||
httpUrlFromWebSocketUrl(hubWebSocketUrl)
|
||||
);
|
||||
const bridgePackageSpec =
|
||||
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
||||
|
|
@ -4505,6 +4575,16 @@ function readConfig() {
|
|||
),
|
||||
hubInternalAccessToken:
|
||||
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
||||
ontologyMcpEnabled: isTruthy(
|
||||
optionalString(process.env.AI_WORKSPACE_ONTOLOGY_MCP_ENABLED) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_ONTOLOGY_MCP_ENABLED) ||
|
||||
"false"
|
||||
) && !isFalsy(
|
||||
optionalString(process.env.AI_WORKSPACE_ONTOLOGY_MCP_ENABLED) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_ONTOLOGY_MCP_ENABLED) ||
|
||||
"false"
|
||||
),
|
||||
ontologyMcpPublicBaseUrl,
|
||||
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
||||
launcherInternalAccessToken,
|
||||
opsGatewayBaseUrl: opsGatewayBaseUrl.replace(/\/+$/, ""),
|
||||
|
|
|
|||
|
|
@ -3457,9 +3457,27 @@ function normalizeTypeName(value) {
|
|||
function fullNodeType(value) {
|
||||
const raw = cleanString(value, 240)
|
||||
if (!raw) return ''
|
||||
if (isPackageQualifiedNodeType(raw)) return raw
|
||||
return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}`
|
||||
}
|
||||
|
||||
function isPackageQualifiedNodeType(value) {
|
||||
return /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+\.[a-z0-9._-]+$/i.test(cleanString(value, 240))
|
||||
}
|
||||
|
||||
function catalogNodeRuntimeType(node) {
|
||||
const explicit = [node?.runtimeType, node?.fullType, node?.publicType, node?.type]
|
||||
.map((value) => cleanString(value, 240))
|
||||
.find(isPackageQualifiedNodeType)
|
||||
if (explicit) return explicit
|
||||
const packageName = cleanString(node?.packageName || node?.package, 160)
|
||||
const name = cleanString(node?.name, 160)
|
||||
if (name && /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+$/i.test(packageName)) {
|
||||
return `${packageName}.${name}`
|
||||
}
|
||||
return fullNodeType(node?.publicType || name)
|
||||
}
|
||||
|
||||
function cleanWebhookSegment(value, fallback) {
|
||||
const out = cleanString(value, 240)
|
||||
.toLowerCase()
|
||||
|
|
@ -3528,7 +3546,8 @@ function compactNodeDefinition(node, maxProperties = 80) {
|
|||
const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : []
|
||||
return {
|
||||
name: node?.name || '',
|
||||
fullType: fullNodeType(node?.name || ''),
|
||||
publicType: node?.publicType || node?.name || '',
|
||||
fullType: catalogNodeRuntimeType(node),
|
||||
displayName: node?.displayName || node?.name || '',
|
||||
description: node?.description || '',
|
||||
group: node?.group || [],
|
||||
|
|
@ -3712,7 +3731,8 @@ async function handleSearchNodes(args) {
|
|||
.slice(0, limit)
|
||||
.map(({ node }) => ({
|
||||
name: node?.name || '',
|
||||
fullType: fullNodeType(node?.name || ''),
|
||||
publicType: node?.publicType || node?.name || '',
|
||||
fullType: catalogNodeRuntimeType(node),
|
||||
displayName: node?.displayName || node?.name || '',
|
||||
description: node?.description || '',
|
||||
group: node?.group || [],
|
||||
|
|
@ -3727,7 +3747,8 @@ async function handleGetNodeDefinition(args) {
|
|||
const catalog = await loadNodeCatalog(args.schemaVersion)
|
||||
const node = catalog.find((item) => (
|
||||
String(item?.name || '') === nodeType ||
|
||||
fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name)
|
||||
String(item?.publicType || '') === nodeType ||
|
||||
catalogNodeRuntimeType(item) === cleanString(args.nodeType || args.type || args.name)
|
||||
))
|
||||
if (!node) throw new Error(`node_not_found:${nodeType}`)
|
||||
return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"dev": "node --watch src/server.mjs"
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"smoke:ontology-mcp-proxy": "node src/scripts/smoke-ontology-mcp-proxy.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { WebSocket } from 'ws'
|
||||
import { createOntologyMcpServer } from '../../../ontology-core/src/mcp-server.mjs'
|
||||
|
||||
const INTERNAL_TOKEN = 'hub-ontology-mcp-smoke-token'
|
||||
const PAIRING_CODE = 'ONTOLOGYSMOKE1'
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
const hubRoot = path.resolve(path.dirname(currentFile), '..', '..')
|
||||
|
||||
const ontologyPort = await availablePort()
|
||||
const hubPort = await availablePort()
|
||||
const ontologyServer = createOntologyMcpServer({
|
||||
internalTokens: [INTERNAL_TOKEN],
|
||||
allowedOrigins: [],
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
})
|
||||
const hubProcess = spawn(process.execPath, ['src/server.mjs'], {
|
||||
cwd: hubRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(hubPort),
|
||||
AI_WORKSPACE_HUB_TOKEN: INTERNAL_TOKEN,
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: INTERNAL_TOKEN,
|
||||
NODEDC_ONTOLOGY_CORE_URL: `http://127.0.0.1:${ontologyPort}`,
|
||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL: 'http://127.0.0.1:1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let hubOutput = ''
|
||||
hubProcess.stdout.on('data', (chunk) => { hubOutput += String(chunk) })
|
||||
hubProcess.stderr.on('data', (chunk) => { hubOutput += String(chunk) })
|
||||
let worker = null
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => ontologyServer.listen(ontologyPort, '127.0.0.1', resolve))
|
||||
await waitForHub(`http://127.0.0.1:${hubPort}/healthz`, hubProcess)
|
||||
|
||||
worker = new WebSocket(
|
||||
`ws://127.0.0.1:${hubPort}/api/ai-workspace/hub?pairingCode=${PAIRING_CODE}&machineName=ontology-smoke`,
|
||||
)
|
||||
await once(worker, 'open')
|
||||
worker.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
agentVersion: 'smoke',
|
||||
protocolVersion: 'ai-workspace-bridge/v1',
|
||||
capabilities: ['smoke'],
|
||||
}))
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${hubPort}/api/ai-workspace/hub/v1/ontology-mcp/${PAIRING_CODE}/mcp`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
'mcp-protocol-version': '2025-06-18',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'ontology_get_entity',
|
||||
arguments: { term: 'helius' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
const payload = await response.json()
|
||||
assert.equal(response.ok, true)
|
||||
assert.equal(response.headers.get('mcp-protocol-version'), '2025-06-18')
|
||||
assert.equal(payload.result.structuredContent.entity.id, 'gelios.integration')
|
||||
|
||||
worker.close()
|
||||
await once(worker, 'close')
|
||||
const offline = await fetch(
|
||||
`http://127.0.0.1:${hubPort}/api/ai-workspace/hub/v1/ontology-mcp/${PAIRING_CODE}/mcp`,
|
||||
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' },
|
||||
)
|
||||
assert.equal(offline.status, 503)
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
'pairing_bound_hub_proxy',
|
||||
'internal_bearer_replaced_by_hub',
|
||||
'ontology_mcp_tool_response',
|
||||
'mcp_protocol_header_forwarded',
|
||||
'offline_pairing_is_rejected',
|
||||
],
|
||||
}, null, 2))
|
||||
} finally {
|
||||
if (worker && worker.readyState === WebSocket.OPEN) worker.close()
|
||||
if (hubProcess.exitCode === null && !hubProcess.killed) {
|
||||
hubProcess.kill('SIGTERM')
|
||||
await once(hubProcess, 'exit').catch(() => {})
|
||||
}
|
||||
await new Promise((resolve, reject) => ontologyServer.close((error) => error ? reject(error) : resolve()))
|
||||
if (hubProcess.exitCode && hubProcess.exitCode !== 0) {
|
||||
throw new Error(`hub_smoke_child_failed:${hubOutput.slice(-2000)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = createServer()
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
return port
|
||||
}
|
||||
|
||||
async function waitForHub(url, processRef) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (processRef.exitCode !== null) throw new Error('hub_smoke_child_exited_early')
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) return
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error('hub_smoke_health_timeout')
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ app.get("/healthz", (_req, res) => {
|
|||
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
|
||||
assistantRelays: assistantRelaysById.size,
|
||||
internalApiConfigured: config.internalAccessTokens.length > 0,
|
||||
ontologyMcpProxyConfigured: Boolean(config.ontologyCoreUrl && config.ontologyCoreAccessToken),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -85,6 +86,7 @@ app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInter
|
|||
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/results/:callId", requireInternalApi, asyncRoute(completeAssistantRelayCall));
|
||||
|
||||
app.use("/api/ai-workspace/hub/v1/ndc-agent-mcp/:pairingCode", asyncRoute(proxyNdcAgentMcp));
|
||||
app.use("/api/ai-workspace/hub/v1/ontology-mcp/:pairingCode", asyncRoute(proxyOntologyMcp));
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
const status = Number(error?.status || 500);
|
||||
|
|
@ -288,6 +290,60 @@ async function proxyNdcAgentMcp(req, res) {
|
|||
res.send(text);
|
||||
}
|
||||
|
||||
async function proxyOntologyMcp(req, res) {
|
||||
if (!config.ontologyCoreUrl || !config.ontologyCoreAccessToken) {
|
||||
res.status(503).json({ ok: false, error: "ontology_mcp_proxy_not_configured" });
|
||||
return;
|
||||
}
|
||||
const pairingCode = cleanPairingCode(req.params.pairingCode);
|
||||
const agent = agentsByCode.get(pairingCode);
|
||||
if (!agent || !isAgentOnline(agent)) {
|
||||
res.status(503).json({ ok: false, error: "bridge_agent_offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = `/api/ai-workspace/hub/v1/ontology-mcp/${encodeURIComponent(req.params.pairingCode)}`;
|
||||
const suffix = String(req.originalUrl || "").startsWith(marker)
|
||||
? String(req.originalUrl || "").slice(marker.length)
|
||||
: String(req.url || "");
|
||||
const targetUrl = `${config.ontologyCoreUrl.replace(/\/+$/, "")}${suffix || "/mcp"}`;
|
||||
await proxyInternalMcpRequest(req, res, targetUrl, config.ontologyCoreAccessToken);
|
||||
}
|
||||
|
||||
async function proxyInternalMcpRequest(req, res, targetUrl, accessToken) {
|
||||
const method = String(req.method || "GET").toUpperCase();
|
||||
const hasBody = !["GET", "HEAD"].includes(method);
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: String(req.headers.accept || "application/json, text/event-stream"),
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...forwardMcpHeaders(req),
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(hasBody ? { body: JSON.stringify(req.body || {}) } : {}),
|
||||
});
|
||||
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
const text = await upstream.text();
|
||||
res.status(upstream.status);
|
||||
res.setHeader("content-type", contentType);
|
||||
for (const header of ["cache-control", "mcp-protocol-version", "mcp-session-id", "vary"]) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) res.setHeader(header, value);
|
||||
}
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
function forwardMcpHeaders(req) {
|
||||
const headers = {};
|
||||
for (const name of ["mcp-protocol-version", "mcp-session-id", "last-event-id"]) {
|
||||
const value = cleanString(req.headers[name], 1000);
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function proxyAssistantActions(req, res) {
|
||||
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
||||
res.status(503).json({ ok: false, error: "assistant_action_proxy_not_configured" });
|
||||
|
|
@ -793,6 +849,13 @@ function readConfig() {
|
|||
1000,
|
||||
).replace(/\/+$/, ""),
|
||||
assistantInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||
ontologyCoreUrl: cleanString(
|
||||
process.env.NODEDC_ONTOLOGY_CORE_URL ||
|
||||
process.env.NDC_ONTOLOGY_CORE_URL ||
|
||||
"http://ontology-core:18104",
|
||||
1000,
|
||||
).replace(/\/+$/, ""),
|
||||
ontologyCoreAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue