feat(data-plane): add provider contracts and ontology delivery
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
# External Provider Contract
|
||||
|
||||
Этот package — единственное общее место для формы внешних интеграций NODE.DC.
|
||||
Он не содержит provider secrets, customer records, runtime payloads или
|
||||
исполняемый connector code. `src/index.mjs` даёт dependency-free проверку
|
||||
минимальных v1 contracts; она запускается через `npm run check`.
|
||||
|
||||
Каждый L2 connector instance должен поставлять совместимые versioned
|
||||
артефакты:
|
||||
|
||||
```text
|
||||
provider-manifest
|
||||
connection-profile
|
||||
capability-catalog
|
||||
field-catalog
|
||||
collection-profile
|
||||
retention-profile
|
||||
semantic-mapping-contract
|
||||
read-model-contract
|
||||
command-catalog (metadata only until a red command gateway is approved)
|
||||
```
|
||||
|
||||
`provider-manifest` — декларативный template-артефакт: provider ID, ontology
|
||||
revision, L2 template version, capability catalog и data-product IDs. Он не
|
||||
является tenant connection, не содержит endpoint/URL, credential reference,
|
||||
secret, ручной scope или executable provider code. Concrete non-secret
|
||||
connection profile принадлежит и версионируется вместе с конкретным L2
|
||||
workflow; Engine только привязывает к нему opaque credential reference и grant.
|
||||
|
||||
## Проверяемые v1 contracts
|
||||
|
||||
- `Connection` — provider instance, tenant scope и *ссылка* на credential в
|
||||
Engine. Любые token/secret/password-like поля запрещены.
|
||||
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
|
||||
polling interval; `realtime` требует interval не чаще одного раза в секунду.
|
||||
- `Data Product` — нормализованный versioned output с semantic types, полями и
|
||||
внутренней аудиторией.
|
||||
- `Intake Batch` — canonical **scoped** record, который External Data Plane
|
||||
валидирует и сохраняет: source, contract revision, idempotency, restricted
|
||||
raw envelope и canonical facts. Его `source` содержит `providerId`,
|
||||
`tenantId` и `connectionId`. Writer-bound L2 request намеренно не является
|
||||
готовым `Intake Batch`: Data Plane сначала materializes scope и лишь затем
|
||||
применяет этот contract. В canonical record нет provider field mapping,
|
||||
entity allowlist, token или renderer data. Inline `raw.payload` в v1
|
||||
запрещён: если нужна provenance-ссылка, connector передаёт restricted
|
||||
`raw.ref` вместе с hash. Отдельный raw-vault может быть добавлен только
|
||||
отдельным ADR и не становится частью L2 → Data Plane wire boundary.
|
||||
- `NDC Foundry Binding` — адресует data product только в конкретную цепочку
|
||||
`Foundry Application → Page → approved slot`; `templateId` можно сохранить
|
||||
как дополнительную типизацию, но он не заменяет `applicationId` и `pageId`.
|
||||
В binding запрещены provider transport, endpoint и credential reference.
|
||||
- `Provider Manifest` — статическое описание L2 connector template, capability
|
||||
catalog и ontology/data-product contracts. Оно не может содержать tenant,
|
||||
connection, credential, secret или provider transport.
|
||||
|
||||
## Data Product delivery contracts
|
||||
|
||||
Provider-neutral runtime использует отдельные wire schemas:
|
||||
|
||||
- `nodedc.data-product.publish/v1` — unscoped publish request от
|
||||
`NDC Data Product Publish`; содержит только batch identity и canonical facts;
|
||||
- `nodedc.data-product.snapshot/v1` — согласованный current snapshot с cursor;
|
||||
- `nodedc.data-product.patch/v1` — committed upsert operations из durable
|
||||
outbox с `previousCursor`/`cursor`.
|
||||
|
||||
Publish request не может задавать provider, tenant, connection, endpoint,
|
||||
receivedAt, ontology revision, version или persistence policy. Data Plane
|
||||
materializes эти значения из opaque writer binding и зарегистрированного Data
|
||||
Product definition. Snapshot/stream читаются только по отдельному opaque reader
|
||||
binding; shared internal bearer и caller-provided scope headers являются legacy
|
||||
и не используются новыми nodes/Foundry runtime.
|
||||
|
||||
`snapshot+patch` v1 — bounded contract: один scoped Data Product содержит не
|
||||
более 5000 current entity keys `(sourceId, semanticType)`. Snapshot обязан быть
|
||||
полным и привязанным к одному repeatable-read cursor. Параметр `limit` задаёт
|
||||
защитный ceiling, не page size: превышение возвращает
|
||||
`413 data_product_snapshot_limit_exceeded`, а reader не имеет права начинать
|
||||
stream с усечённой базой. `nextPageCursor` зарезервирован для будущего отдельно
|
||||
версионируемого query contract и текущим bounded runtime не выдаётся.
|
||||
|
||||
Для большей cardinality definition заранее раскладывается по стабильным
|
||||
partition Data Products с независимыми snapshot/patch cursors либо использует
|
||||
будущий query contract с единым snapshot barrier и continuation semantics.
|
||||
Offset/source-ID pagination поверх меняющегося current snapshot запрещена:
|
||||
между страницами она способна потерять или задублировать изменения относительно
|
||||
patch cursor.
|
||||
|
||||
Для каждого canonical fact действует одинаковый hard ceiling: сериализованный
|
||||
`attributes` не больше 64 KiB как на publish/intake входе, так и в
|
||||
snapshot/patch выходе. GeoJSON `Point` принимает longitude только в
|
||||
`[-180, 180]`, latitude в `[-90, 90]`; batch sequence ограничен диапазоном
|
||||
PostgreSQL `integer` `0..2147483647`. Эти ограничения нельзя ослабить опциями
|
||||
конкретного caller-а.
|
||||
|
||||
Manifest, connection/collection profiles, data-product definition и Foundry
|
||||
binding используют fail-closed allowed-key schemas. Неизвестные поля, а также
|
||||
secret-like имена или значения (включая `ndc_edpwb_`/`ndc_edprb_`) отклоняются
|
||||
на общей границе.
|
||||
|
||||
Все private custom nodes NODE.DC поставляются package
|
||||
`platform/packages/n8n-nodes-ndc`. Их display name обязан начинаться с `NDC `,
|
||||
а runtime type — с `n8n-nodes-ndc.`; package называется строго
|
||||
`n8n-nodes-ndc`. Provider-specific adapters остаются L2 workflow logic и не
|
||||
становятся custom nodes или ветками Data Plane. Эти инварианты проверяются
|
||||
package test.
|
||||
|
||||
Control-plane команда `NDC Foundry Binding` имеет отдельную replay-safe schema
|
||||
`nodedc.foundry.binding-upsert/v1` (`validateFoundryBindingUpsert`). Это не
|
||||
declarative provider artifact и не runtime transport: команда содержит только
|
||||
application/page/binding/data-product projection и idempotency key, а право на
|
||||
операцию извлекается Foundry из отдельного opaque workload grant.
|
||||
|
||||
## Engine opaque credential sink
|
||||
|
||||
`src/engine-credential-sink.mjs` задаёт dependency-free server-to-server v1
|
||||
границу для доставки трёх workload capabilities в Engine Credentials:
|
||||
|
||||
- `external-data-plane.writer` → точная нода
|
||||
`n8n-nodes-ndc.ndcDataProductPublish` / `ndcDataProductWriterApi`;
|
||||
- `external-data-plane.reader` → точная нода
|
||||
`n8n-nodes-ndc.ndcDataProductRead` / `ndcDataProductReaderApi`;
|
||||
- `foundry.binding` → точная нода
|
||||
`n8n-nodes-ndc.ndcFoundryBinding` / `ndcFoundryBindingApi`.
|
||||
|
||||
Provision request фиксирует `workflowId`, `workflowRevision`, `nodeId`, runtime
|
||||
node type, credential type, grant ID, expiry и issuer policy hash. Aggregate
|
||||
`transaction.policyHash` детерминированно считается по всему secret-free
|
||||
descriptor; подмена любой цели или policy ломает валидацию. Единственное поле,
|
||||
которое переносит plaintext capability, — `bindings[].material.value`; request
|
||||
нельзя писать в логи, traces, очередь или audit.
|
||||
|
||||
Provision и rollback envelopes действуют не более 15 минут: sink отклоняет
|
||||
истёкшие запросы и допускает максимум 60 секунд положительного clock skew.
|
||||
Receipt повторно проверяет freshness относительно собственного `processedAt`,
|
||||
чтобы старый запрос нельзя было применить или подтвердить через replay.
|
||||
|
||||
Каждый binding содержит `capabilityDigest = sha256(material.value)`: Engine
|
||||
самостоятельно хеширует полученный plaintext и сравнивает digest. Aggregate
|
||||
`policyHash` включает этот digest и issuer identity, а provision transaction
|
||||
несёт обязательную Ed25519 attestation. Engine принимает её только по
|
||||
allowlisted `serviceId:keyId`; заменить capability и пересчитать обычный hash
|
||||
без приватного issuer key невозможно.
|
||||
|
||||
Sink обязан выполнять `rollback-all`: сначала проверить весь request и точное
|
||||
состояние graph, затем создать credentials в staging, атомарно привязать весь
|
||||
набор и только после commit вернуть opaque `credentialRef`. При любой ошибке
|
||||
новые credentials удаляются, а прежние bindings остаются без изменений.
|
||||
`rollback-failed` означает карантин и ручное восстановление, но никогда не
|
||||
возвращает частичные credential refs.
|
||||
|
||||
Receipt, rollback request/receipt и audit имеют отдельные strict schemas. Они
|
||||
не способны вернуть capability material; audit хранит только hash opaque
|
||||
credential reference. Explicit rollback адресует committed transaction через
|
||||
`transactionId`, `policyHash` и hash committed receipt, поэтому не может
|
||||
случайно откатить другой набор. Реализация sink принадлежит Engine и не даёт
|
||||
Platform/Codex доступа к Engine core, runtime files или plaintext credential
|
||||
storage.
|
||||
|
||||
## Engine private-extension management
|
||||
|
||||
`src/engine-private-extension.mjs` задаёт строгую Platform-side границу для
|
||||
Engine-owned активации проверенного `n8n-nodes-ndc` release. Контракт не
|
||||
устанавливает package и не меняет Engine: он фиксирует async
|
||||
`plan -> apply receipt -> operation/status` protocol, где `apply` обязан
|
||||
быстро вернуть `queued` и `operationId`, а долгий recreate/acceptance
|
||||
отслеживается отдельно.
|
||||
|
||||
Активация и rollback требуют отдельной глобальной capability
|
||||
`engine.private-extension.manage`. Обычные L1/L2 grants её не дают. Чтение
|
||||
состояния допускает `engine.private-extension.read` или manage-capability.
|
||||
Запрос выбирает только allowlisted package, digest-bound `releaseId` и
|
||||
`packageSha256`; caller не передаёт host path, package bytes, Compose service,
|
||||
shell command или credential material. Plan живёт не более 15 минут, является
|
||||
single-use и применяется только с тем же idempotency key и plan hash.
|
||||
|
||||
Runtime transition для n8n 2.3.2 зафиксирован как community-package loader по
|
||||
`/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, а не как
|
||||
`N8N_CUSTOM_EXTENSIONS` или `CUSTOM.*` loader:
|
||||
|
||||
- `N8N_COMMUNITY_PACKAGES_ENABLED=true`,
|
||||
`N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=false`,
|
||||
`N8N_REINSTALL_MISSING_PACKAGES=false`;
|
||||
- Deploy/Run quiesced and execution queue drained before the version switch;
|
||||
- read-only mount and atomic current/recovery state;
|
||||
- force-recreate main, every worker and every webhook instance as one version
|
||||
barrier; hot reload запрещён;
|
||||
- acceptance требует единый generation и точный набор трёх package-qualified
|
||||
node schemas и трёх credential schemas.
|
||||
|
||||
Любая ошибка после switch запускает automatic rollback и повторный
|
||||
force-recreate/acceptance. Ошибка самого rollback переводит runtime в
|
||||
`quarantined`. Immutable release и существующие Engine Credentials
|
||||
сохраняются. Для первой активации предыдущим проверенным состоянием является
|
||||
`n8n-nodes-ndc.inactive/v1`: rollback в этот baseline удаляет package из
|
||||
loader surface, но не удаляет credentials.
|
||||
|
||||
Public Ops gateway уже умеет прозрачно передавать эти операции через
|
||||
`/engine/mcp`, если Engine реализует соответствующие MCP tools. Так как
|
||||
gateway имеет 30-second upstream timeout, side effect остаётся асинхронным;
|
||||
отдельный public REST proxy для management boundary не требуется.
|
||||
|
||||
Пример `examples/gelios-positions-current.v1.mjs` — provider-specific fixture
|
||||
без customer, tenant identity или credential material.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `platform/packages/external-provider-contract` — общий контракт и schemas.
|
||||
- NDC Agent L2 — provider API adapter: fetch, pagination, batching,
|
||||
semantic mapping, collection profile и ссылка на credential в Engine.
|
||||
- Platform External Data Plane — provider-neutral intake, raw retention,
|
||||
canonical facts, current/history projections и scoped read products. Он не
|
||||
знает provider fields, customer/business filters или renderer rules.
|
||||
- `platform/services/ontology-core/catalog/domain-packages/<provider>` —
|
||||
семантика, отношения и guardrails, но не runtime data.
|
||||
- Foundry/interface bindings — consumers scoped data product; они не получают
|
||||
provider transport, endpoint или credential reference.
|
||||
|
||||
`services/<provider>-gateway` — устаревший experimental path и не является
|
||||
шаблоном новых integration services. Канон описан в
|
||||
`docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.
|
||||
|
||||
## Mandatory connection boundary
|
||||
|
||||
`connection` принадлежит одному tenant/client context и содержит только
|
||||
ссылку на секрет, утверждённый capability scope, collection profile, field
|
||||
policy и retention policy. Во всех runtime records Data Plane сохраняет минимум
|
||||
`tenant_id`, `connection_id`, `provider_id`, `observed_at`, `received_at` и
|
||||
provenance/version там, где это применимо.
|
||||
|
||||
Writer token — отдельный EDP runtime credential, а не поле `Connection`,
|
||||
profile, provider manifest или L2 graph.
|
||||
|
||||
## Scoped writer binding
|
||||
|
||||
Writer binding — EDP-owned runtime security state, а не versioned artifact
|
||||
provider manifest или connection profile. Он фиксирует `tenantId`,
|
||||
`connectionId`, `providerId`, `allowedDataProductIds`, active/revoked state и
|
||||
`expiresAt`. L2 не может редактировать binding или задавать его scope;
|
||||
изменение любого scope-поля либо TTL создаёт новый binding, а прежний binding
|
||||
можно только rotate/revoke.
|
||||
|
||||
Новый L2 вызывает `POST /internal/data-plane/v1/intake/writer-bound` с
|
||||
`Authorization: Bearer <writer-token>` и unscoped envelope. В `source`
|
||||
разрешён только `providerId`; `tenantId`, `connectionId` и
|
||||
`x-nodedc-*-id` headers запрещены. EDP проверяет token, binding, provider и
|
||||
`contract.dataProductId`, затем materializes immutable canonical scope и
|
||||
сохраняет обычный `Intake Batch`. Caller-provided scope отклоняется, а не
|
||||
доверяется и не объединяется с binding.
|
||||
|
||||
Plaintext writer token возвращается только trusted provisioner при создании
|
||||
или rotation. Provisioner помещает его непосредственно в opaque Engine
|
||||
credential, доступный назначенному L2. EDP хранит только hash token и binding
|
||||
metadata; token запрещён в connection/profile/manifest, L2 graph, logs/traces,
|
||||
raw payload, Foundry и UI.
|
||||
|
||||
Provisioning routes принимают только отдельный secret file, созданный
|
||||
root-owned deploy runner:
|
||||
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`.
|
||||
Он read-only монтируется только в EDP и позднее — в dedicated Engine
|
||||
provisioner, работающий под выделенным UID/GID `11006`; это не `.env` value,
|
||||
не `NODEDC_INTERNAL_ACCESS_TOKEN` и не provider credential. Пока generic Engine
|
||||
provisioner не развёрнут, `EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false`, а
|
||||
create/rotate/revoke routes отвечают `503` и не делают fallback к shared
|
||||
internal bearer.
|
||||
|
||||
## Legacy intake migration
|
||||
|
||||
`POST /internal/data-plane/v1/intake` — временный compatibility route для
|
||||
existing writers. Он принимает только canonical scoped `Intake Batch` под
|
||||
`NODEDC_INTERNAL_ACCESS_TOKEN` и требует совпадения body scope с
|
||||
`x-nodedc-tenant-id` и `x-nodedc-connection-id`. Writer token этот route не
|
||||
заменяет.
|
||||
|
||||
Для миграции: создать writer binding, один раз записать выданный token в Engine
|
||||
credential, переключить L2 на `/intake/writer-bound`, удалить tenant/connection
|
||||
из body и headers, проверить успешный intake, затем удалить у L2 legacy
|
||||
credential. Новый или migrated writer не должен fallback-иться на legacy route
|
||||
после ошибки writer-bound intake. Когда migrated все writers, legacy route и
|
||||
его shared-token access удаляются.
|
||||
|
||||
## Intake boundary
|
||||
|
||||
L2 отправляет в общий Data Plane `Intake Batch`, а не SQL-запрос в общие
|
||||
таблицы. Platform проверяет boundary и сохраняет raw/history/current
|
||||
projections; semantic mapping остаётся в L2. Список конкретных source IDs не
|
||||
является частью connection scope: scope выбирает read-capability API, а
|
||||
visibility решает data-product consumer.
|
||||
|
||||
Inline `raw.payload` запрещён: L2 не может записать в Data Plane полный ответ
|
||||
provider-а или произвольную строку. Пока отдельный raw-vault не утверждён,
|
||||
batch либо не содержит `raw`, либо содержит только restricted `raw.ref` и hash.
|
||||
Secret-like keys и распознаваемые bearer/JWT/writer-token values в любом участке
|
||||
canonical batch, включая `facts[].attributes`, отклоняются. Retention raw
|
||||
reference вычисляется от server acceptance time, а не от `batch.receivedAt`;
|
||||
service делает sweep expired envelopes при старте и по расписанию.
|
||||
|
||||
## Safety classification
|
||||
|
||||
Capabilities классифицируются как `read`, `metadata`, `write`, `destructive`
|
||||
или `unknown`. Только `read` и согласованные `metadata` могут попасть в
|
||||
collector. `write` и `destructive` остаются каталогизированными, но не имеют
|
||||
transport route в read adapter.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "../src/index.mjs";
|
||||
|
||||
export const geliosPositionsCurrentExample = Object.freeze({
|
||||
providerManifest: {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "gelios.positions.current",
|
||||
providerId: "gelios",
|
||||
version: "1.0.0",
|
||||
ontology: {
|
||||
packageId: "gelios",
|
||||
revision: "gelios.positions.v1",
|
||||
},
|
||||
l2Template: {
|
||||
id: "gelios.positions.current",
|
||||
version: "1.0.0",
|
||||
},
|
||||
capabilities: [
|
||||
{ id: "gelios.units.current.read", classification: "read" },
|
||||
{ id: "gelios.units.command.write", classification: "write" },
|
||||
],
|
||||
dataProductIds: ["fleet.positions.current.v1"],
|
||||
},
|
||||
connection: {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "gelios.sample-fleet",
|
||||
providerId: "gelios",
|
||||
tenantId: "sample-tenant",
|
||||
credentialRef: { owner: "engine", reference: "engine-credential-reference" },
|
||||
scope: {
|
||||
capabilityIds: ["gelios.units.current.read"],
|
||||
fieldPolicyId: "fleet.position.display.v1",
|
||||
},
|
||||
},
|
||||
collectionProfile: {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "gelios.positions.realtime",
|
||||
connectionId: "gelios.sample-fleet",
|
||||
mode: "realtime",
|
||||
schedule: { intervalMs: 15000 },
|
||||
capabilityIds: ["gelios.units.current.read"],
|
||||
dataProductId: "fleet.positions.current.v1",
|
||||
},
|
||||
dataProduct: {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "fleet.positions.current.v1",
|
||||
version: "1.0.0",
|
||||
delivery: { mode: "snapshot+patch" },
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fields: [
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"object_kind",
|
||||
"operational_status",
|
||||
"position_source",
|
||||
"position_valid",
|
||||
"quality_flags",
|
||||
"satellite_count",
|
||||
"speed_kph",
|
||||
],
|
||||
access: { audience: "internal" },
|
||||
},
|
||||
foundryBinding: {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "fleet.operations-map.moving-objects",
|
||||
dataProductId: "fleet.positions.current.v1",
|
||||
applicationId: "11111111-1111-4111-8111-111111111111",
|
||||
pageId: "map",
|
||||
templateId: "map",
|
||||
slotId: "points",
|
||||
semanticType: "map.moving_object",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@nodedc/external-provider-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.mjs",
|
||||
"scripts": {
|
||||
"check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/engine-credential-sink.test.mjs && node test/engine-private-extension.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export const DATA_PRODUCT_PUBLISH_SCHEMA_VERSION = "nodedc.data-product.publish/v1";
|
||||
export const DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION = "nodedc.data-product.snapshot/v1";
|
||||
export const DATA_PRODUCT_PATCH_SCHEMA_VERSION = "nodedc.data-product.patch/v1";
|
||||
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
const CURSOR = /^(?:0|[1-9]\d*)$/;
|
||||
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_-]*|[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
||||
const MAX_BATCH_SEQUENCE = 2_147_483_647;
|
||||
const MAX_FACT_ATTRIBUTES_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Wire form accepted from an NDC Data Product Publish node.
|
||||
*
|
||||
* Scope, provider identity, product version, ontology revision and storage
|
||||
* policy are deliberately absent: the Data Plane materializes them from the
|
||||
* opaque writer grant and its product registry.
|
||||
*/
|
||||
export function validateDataProductPublish(value, { maxFacts = 5000, maxAttributesBytes = 64 * 1024 } = {}) {
|
||||
const errors = [];
|
||||
const attributesCeiling = Number.isInteger(maxAttributesBytes) && maxAttributesBytes > 0
|
||||
? Math.min(maxAttributesBytes, MAX_FACT_ATTRIBUTES_BYTES)
|
||||
: MAX_FACT_ATTRIBUTES_BYTES;
|
||||
if (!isPlainObject(value)) return result(["publish_must_be_object"]);
|
||||
if (value.schemaVersion !== DATA_PRODUCT_PUBLISH_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, new Set(["schemaVersion", "batch", "facts"]), "publish", errors);
|
||||
|
||||
if (!isPlainObject(value.batch)) {
|
||||
errors.push("batch_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.batch, new Set(["runId", "sequence", "idempotencyKey"]), "batch", errors);
|
||||
requiredIdentifier(value.batch.runId, "batch.runId", errors);
|
||||
requiredIdentifier(value.batch.idempotencyKey, "batch.idempotencyKey", errors);
|
||||
if (!Number.isInteger(value.batch.sequence) || value.batch.sequence < 0 || value.batch.sequence > MAX_BATCH_SEQUENCE) {
|
||||
errors.push("batch.sequence_must_be_integer_0_to_2147483647");
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.facts) || value.facts.length === 0) {
|
||||
errors.push("facts_must_be_nonempty_array");
|
||||
} else if (value.facts.length > maxFacts) {
|
||||
errors.push("facts_limit_exceeded");
|
||||
} else {
|
||||
const entityKeys = new Set();
|
||||
value.facts.forEach((fact, index) => {
|
||||
validateFact(fact, `facts[${index}]`, errors, { maxAttributesBytes: attributesCeiling });
|
||||
if (!isPlainObject(fact) || typeof fact.sourceId !== "string" || typeof fact.semanticType !== "string") return;
|
||||
const entityKey = `${fact.sourceId}\u0000${fact.semanticType}`;
|
||||
if (entityKeys.has(entityKey)) errors.push("facts_duplicate_entity_key");
|
||||
entityKeys.add(entityKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (containsSecretLikeMaterial(value)) errors.push("publish_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateDataProductSnapshot(value) {
|
||||
const errors = envelopeErrors(value, DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, "snapshot");
|
||||
rejectUnknownKeys(value, new Set(["schemaVersion", "dataProduct", "generatedAt", "cursor", "facts", "nextPageCursor"]), "snapshot", errors);
|
||||
requiredCursor(value?.cursor, "cursor", errors);
|
||||
requiredIsoTimestamp(value?.generatedAt, "generatedAt", errors);
|
||||
if (!Array.isArray(value?.facts)) {
|
||||
errors.push("facts_must_be_array");
|
||||
} else {
|
||||
value.facts.forEach((fact, index) => validateCanonicalFact(fact, `facts[${index}]`, errors));
|
||||
}
|
||||
if (value?.nextPageCursor !== undefined) requiredString(value.nextPageCursor, "nextPageCursor", errors);
|
||||
if (containsSecretLikeMaterial(value)) errors.push("snapshot_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateDataProductPatch(value) {
|
||||
const errors = envelopeErrors(value, DATA_PRODUCT_PATCH_SCHEMA_VERSION, "patch");
|
||||
rejectUnknownKeys(value, new Set(["schemaVersion", "dataProduct", "cursor", "previousCursor", "emittedAt", "operations"]), "patch", errors);
|
||||
requiredCursor(value?.cursor, "cursor", errors);
|
||||
requiredCursor(value?.previousCursor, "previousCursor", errors);
|
||||
requiredIsoTimestamp(value?.emittedAt, "emittedAt", errors);
|
||||
if (!Array.isArray(value?.operations) || value.operations.length === 0) {
|
||||
errors.push("operations_must_be_nonempty_array");
|
||||
} else {
|
||||
value.operations.forEach((operation, index) => {
|
||||
if (!isPlainObject(operation) || operation.op !== "upsert") {
|
||||
errors.push(`operations[${index}].op_must_be_upsert`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(operation, new Set(["op", "fact"]), `operations[${index}]`, errors);
|
||||
validateCanonicalFact(operation.fact, `operations[${index}].fact`, errors);
|
||||
});
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("patch_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
function envelopeErrors(value, schemaVersion, label) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return [`${label}_must_be_object`];
|
||||
if (value.schemaVersion !== schemaVersion) errors.push("schemaVersion_mismatch");
|
||||
if (!isPlainObject(value.dataProduct)) {
|
||||
errors.push("dataProduct_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.dataProduct, new Set(["id", "version"]), "dataProduct", errors);
|
||||
requiredIdentifier(value.dataProduct.id, "dataProduct.id", errors);
|
||||
requiredString(value.dataProduct.version, "dataProduct.version", errors);
|
||||
if (value.dataProduct.version && !SEMVER.test(value.dataProduct.version)) errors.push("dataProduct.version_must_be_semver");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFact(value, path, errors, { maxAttributesBytes, canonical = false }) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
const allowedKeys = new Set(["sourceId", "semanticType", "observedAt", "attributes", "geometry"]);
|
||||
if (canonical) allowedKeys.add("receivedAt");
|
||||
rejectUnknownKeys(value, allowedKeys, path, errors);
|
||||
requiredIdentifier(value.sourceId, `${path}.sourceId`, errors);
|
||||
requiredIdentifier(value.semanticType, `${path}.semanticType`, errors);
|
||||
requiredIsoTimestamp(value.observedAt, `${path}.observedAt`, errors);
|
||||
if (value.attributes !== undefined) {
|
||||
if (!isPlainObject(value.attributes)) {
|
||||
errors.push(`${path}.attributes_must_be_object`);
|
||||
} else if (serializedByteLength(value.attributes) > maxAttributesBytes) {
|
||||
errors.push(`${path}.attributes_size_exceeded`);
|
||||
}
|
||||
}
|
||||
if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
}
|
||||
|
||||
function validateCanonicalFact(value, path, errors) {
|
||||
validateFact(value, path, errors, { maxAttributesBytes: 64 * 1024, canonical: true });
|
||||
if (!isPlainObject(value)) return;
|
||||
requiredIsoTimestamp(value.receivedAt, `${path}.receivedAt`, errors);
|
||||
}
|
||||
|
||||
function validatePointGeometry(value, path, errors) {
|
||||
if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
|
||||
errors.push(`${path}_must_be_geojson_point`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors);
|
||||
if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) {
|
||||
errors.push(`${path}_coordinates_must_be_finite_numbers`);
|
||||
return;
|
||||
}
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`);
|
||||
if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`);
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowed, path, errors) {
|
||||
if (!isPlainObject(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredIdentifier(value, path, errors) {
|
||||
if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredString(value, path, errors) {
|
||||
if (typeof value !== "string" || !value.trim()) errors.push(`${path}_required`);
|
||||
}
|
||||
|
||||
function requiredCursor(value, path, errors) {
|
||||
if (typeof value !== "string" || !CURSOR.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredIsoTimestamp(value, path, errors) {
|
||||
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) errors.push(`${path}_invalid_timestamp`);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function serializedByteLength(value) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value));
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
function containsSecretLikeMaterial(value) {
|
||||
if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
|
||||
if (Array.isArray(value)) return value.some(containsSecretLikeMaterial);
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child));
|
||||
}
|
||||
|
||||
function result(errors) {
|
||||
return Object.freeze({ ok: errors.length === 0, errors: Object.freeze([...new Set(errors)]) });
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
import { createHash, verify as verifySignature } from "node:crypto";
|
||||
|
||||
export const ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION = "nodedc.engine.credential-sink.provision/v1";
|
||||
export const ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION = "nodedc.engine.credential-sink.receipt/v1";
|
||||
export const ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION = "nodedc.engine.credential-sink.rollback/v1";
|
||||
export const ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION = "nodedc.engine.credential-sink.rollback-receipt/v1";
|
||||
export const ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION = "nodedc.engine.credential-sink.audit/v1";
|
||||
|
||||
const HASH = /^sha256:[a-f0-9]{64}$/;
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,159}$/;
|
||||
const NODE_TYPE = /^[a-z][A-Za-z0-9.-]{2,159}$/;
|
||||
const CREDENTIAL_TYPE = /^[a-z][A-Za-z0-9]{2,127}$/;
|
||||
const CREDENTIAL_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9_-]{5,159}$/;
|
||||
const REASON_CODE = /^[a-z][a-z0-9_.:-]{2,127}$/;
|
||||
const ED25519_SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
||||
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|material|value)/i;
|
||||
const SECRET_LIKE_VALUE = /(?:ndc_(?:edp(?:wb|rb)|fndbg)_[A-Za-z0-9_-]+|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
||||
const MAX_BINDINGS = 32;
|
||||
const MAX_REQUEST_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const MAX_REQUEST_CLOCK_SKEW_MS = 60 * 1000;
|
||||
|
||||
const CAPABILITY_SPECS = Object.freeze({
|
||||
"external-data-plane.writer": Object.freeze({
|
||||
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
|
||||
credentialType: "ndcDataProductWriterApi",
|
||||
materialPattern: /^ndc_edpwb_[A-Za-z0-9_-]{43}$/,
|
||||
}),
|
||||
"external-data-plane.reader": Object.freeze({
|
||||
nodeType: "n8n-nodes-ndc.ndcDataProductRead",
|
||||
credentialType: "ndcDataProductReaderApi",
|
||||
materialPattern: /^ndc_edprb_[A-Za-z0-9_-]{43}$/,
|
||||
}),
|
||||
"foundry.binding": Object.freeze({
|
||||
nodeType: "n8n-nodes-ndc.ndcFoundryBinding",
|
||||
credentialType: "ndcFoundryBindingApi",
|
||||
materialPattern: /^ndc_fndbg_[A-Za-z0-9_-]{43}$/,
|
||||
}),
|
||||
});
|
||||
|
||||
export const ENGINE_CREDENTIAL_SINK_CAPABILITY_TYPES = Object.freeze(Object.keys(CAPABILITY_SPECS));
|
||||
|
||||
const PROVISION_KEYS = new Set(["schemaVersion", "transaction", "bindings"]);
|
||||
const TRANSACTION_KEYS = new Set([
|
||||
"id",
|
||||
"idempotencyKey",
|
||||
"requestedAt",
|
||||
"requestExpiresAt",
|
||||
"policyHash",
|
||||
"failureMode",
|
||||
"issuer",
|
||||
"attestation",
|
||||
]);
|
||||
const ISSUER_KEYS = new Set(["serviceId", "keyId"]);
|
||||
const ATTESTATION_KEYS = new Set(["algorithm", "signature"]);
|
||||
const BINDING_KEYS = new Set([
|
||||
"bindingId",
|
||||
"capabilityType",
|
||||
"grantId",
|
||||
"target",
|
||||
"expiresAt",
|
||||
"policyHash",
|
||||
"capabilityDigest",
|
||||
"material",
|
||||
]);
|
||||
const TARGET_KEYS = new Set([
|
||||
"workflowId",
|
||||
"workflowRevision",
|
||||
"nodeId",
|
||||
"nodeType",
|
||||
"credentialType",
|
||||
]);
|
||||
const MATERIAL_KEYS = new Set(["format", "value"]);
|
||||
const RECEIPT_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"transactionId",
|
||||
"idempotencyKey",
|
||||
"outcome",
|
||||
"policyHash",
|
||||
"processedAt",
|
||||
"credentials",
|
||||
"rollback",
|
||||
"errorCode",
|
||||
]);
|
||||
const RECEIPT_CREDENTIAL_KEYS = new Set([
|
||||
"bindingId",
|
||||
"capabilityType",
|
||||
"grantId",
|
||||
"target",
|
||||
"credentialRef",
|
||||
"expiresAt",
|
||||
"policyHash",
|
||||
"capabilityDigest",
|
||||
"disposition",
|
||||
]);
|
||||
const RECEIPT_ROLLBACK_KEYS = new Set(["status", "completedAt"]);
|
||||
const ROLLBACK_REQUEST_KEYS = new Set(["schemaVersion", "rollback"]);
|
||||
const ROLLBACK_KEYS = new Set([
|
||||
"id",
|
||||
"idempotencyKey",
|
||||
"transactionId",
|
||||
"requestedAt",
|
||||
"requestExpiresAt",
|
||||
"policyHash",
|
||||
"committedReceiptHash",
|
||||
"reasonCode",
|
||||
]);
|
||||
const ROLLBACK_RECEIPT_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"rollbackId",
|
||||
"transactionId",
|
||||
"outcome",
|
||||
"policyHash",
|
||||
"committedReceiptHash",
|
||||
"processedAt",
|
||||
"errorCode",
|
||||
]);
|
||||
const AUDIT_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"eventId",
|
||||
"transactionId",
|
||||
"operationId",
|
||||
"operation",
|
||||
"outcome",
|
||||
"occurredAt",
|
||||
"policyHash",
|
||||
"principal",
|
||||
"targets",
|
||||
"reasonCode",
|
||||
]);
|
||||
const PRINCIPAL_KEYS = new Set(["serviceId", "fingerprint"]);
|
||||
const AUDIT_TARGET_KEYS = new Set([
|
||||
"bindingId",
|
||||
"capabilityType",
|
||||
"grantId",
|
||||
"target",
|
||||
"expiresAt",
|
||||
"policyHash",
|
||||
"capabilityDigest",
|
||||
"credentialRefHash",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates the only request allowed to carry plaintext workload capability
|
||||
* material across the trusted Platform -> Engine server boundary. Callers and
|
||||
* receivers must never log, trace, persist or return this request body.
|
||||
*/
|
||||
export function validateEngineCredentialSinkProvision(value, { now = Date.now(), issuerPublicKeys } = {}) {
|
||||
const errors = [];
|
||||
const nowMs = normalizeNow(now, errors);
|
||||
if (!isPlainObject(value)) return result(["credentialSinkProvision_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, PROVISION_KEYS, "credentialSinkProvision", errors);
|
||||
|
||||
if (!isPlainObject(value.transaction)) {
|
||||
errors.push("transaction_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.transaction, TRANSACTION_KEYS, "transaction", errors);
|
||||
requiredOpaqueId(value.transaction.id, "transaction.id", errors);
|
||||
requiredOpaqueId(value.transaction.idempotencyKey, "transaction.idempotencyKey", errors);
|
||||
requiredTimestamp(value.transaction.requestedAt, "transaction.requestedAt", errors);
|
||||
requiredTimestamp(value.transaction.requestExpiresAt, "transaction.requestExpiresAt", errors);
|
||||
requiredHash(value.transaction.policyHash, "transaction.policyHash", errors);
|
||||
if (value.transaction.failureMode !== "rollback-all") errors.push("transaction.failureMode_must_be_rollback-all");
|
||||
validateIssuer(value.transaction.issuer, "transaction.issuer", errors);
|
||||
validateAttestation(value.transaction.attestation, "transaction.attestation", errors);
|
||||
validateRequestWindow(value.transaction.requestedAt, value.transaction.requestExpiresAt, nowMs, errors);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.bindings) || value.bindings.length === 0) {
|
||||
errors.push("bindings_must_be_nonempty_array");
|
||||
} else if (value.bindings.length > MAX_BINDINGS) {
|
||||
errors.push("bindings_limit_exceeded");
|
||||
} else {
|
||||
const bindingIds = new Set();
|
||||
const targets = new Set();
|
||||
value.bindings.forEach((binding, index) => {
|
||||
validateProvisionBinding(binding, index, value.transaction?.requestedAt, errors);
|
||||
if (!isPlainObject(binding)) return;
|
||||
if (bindingIds.has(binding.bindingId)) errors.push("bindings_bindingId_must_be_unique");
|
||||
bindingIds.add(binding.bindingId);
|
||||
const targetKey = targetIdentity(binding.target);
|
||||
if (targetKey && targets.has(targetKey)) errors.push("bindings_target_credential_must_be_unique");
|
||||
if (targetKey) targets.add(targetKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (isPlainObject(value.transaction) && HASH.test(String(value.transaction.policyHash || ""))) {
|
||||
const expectedHash = computeEngineCredentialSinkPolicyHash(value);
|
||||
if (value.transaction.policyHash !== expectedHash) errors.push("transaction.policyHash_mismatch");
|
||||
verifyProvisionAttestation(value.transaction, issuerPublicKeys, errors);
|
||||
}
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the aggregate policy digest over the complete secret-free request
|
||||
* descriptor. Plaintext `material` is excluded by construction, while its
|
||||
* high-entropy capability digest is included; changing a target, grant,
|
||||
* capability, expiry, individual policy hash or transaction envelope changes
|
||||
* the aggregate digest and invalidates the issuer attestation.
|
||||
*/
|
||||
export function computeEngineCredentialSinkPolicyHash(value) {
|
||||
const descriptor = {
|
||||
schemaVersion: value?.schemaVersion,
|
||||
transaction: isPlainObject(value?.transaction) ? {
|
||||
id: value.transaction.id,
|
||||
idempotencyKey: value.transaction.idempotencyKey,
|
||||
requestedAt: value.transaction.requestedAt,
|
||||
requestExpiresAt: value.transaction.requestExpiresAt,
|
||||
failureMode: value.transaction.failureMode,
|
||||
issuer: value.transaction.issuer,
|
||||
} : value?.transaction,
|
||||
bindings: Array.isArray(value?.bindings)
|
||||
? value.bindings.map(secretFreeBindingDescriptor)
|
||||
: value?.bindings,
|
||||
};
|
||||
return `sha256:${createHash("sha256").update(stableJson(descriptor), "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receipt is intentionally incapable of carrying credential material. When a
|
||||
* request is supplied, the validator also proves the sink committed the exact
|
||||
* requested workflow/node/type set without target substitution.
|
||||
*/
|
||||
export function validateEngineCredentialSinkReceipt(value, { request, issuerPublicKeys } = {}) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["credentialSinkReceipt_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, RECEIPT_KEYS, "credentialSinkReceipt", errors);
|
||||
requiredOpaqueId(value.transactionId, "transactionId", errors);
|
||||
requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors);
|
||||
requiredHash(value.policyHash, "policyHash", errors);
|
||||
requiredTimestamp(value.processedAt, "processedAt", errors);
|
||||
const outcomes = new Set(["committed", "rolled-back", "rejected", "rollback-failed"]);
|
||||
if (!outcomes.has(value.outcome)) errors.push("outcome_invalid");
|
||||
|
||||
if (!Array.isArray(value.credentials)) {
|
||||
errors.push("credentials_must_be_array");
|
||||
} else {
|
||||
const bindingIds = new Set();
|
||||
const refs = new Set();
|
||||
value.credentials.forEach((credential, index) => {
|
||||
validateReceiptCredential(credential, index, errors);
|
||||
if (!isPlainObject(credential)) return;
|
||||
if (bindingIds.has(credential.bindingId)) errors.push("credentials_bindingId_must_be_unique");
|
||||
bindingIds.add(credential.bindingId);
|
||||
if (refs.has(credential.credentialRef)) errors.push("credentials_credentialRef_must_be_unique");
|
||||
refs.add(credential.credentialRef);
|
||||
});
|
||||
}
|
||||
validateReceiptOutcome(value, errors);
|
||||
if (containsSecretLikeMaterial(value)) errors.push("receipt_must_not_contain_secret_material");
|
||||
if (request !== undefined) compareReceiptToProvisionRequest(value, request, issuerPublicKeys, errors);
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function computeEngineCredentialSinkReceiptHash(value) {
|
||||
return `sha256:${createHash("sha256").update(stableJson(value), "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function validateEngineCredentialSinkRollback(value, { now = Date.now() } = {}) {
|
||||
const errors = [];
|
||||
const nowMs = normalizeNow(now, errors);
|
||||
if (!isPlainObject(value)) return result(["credentialSinkRollback_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, ROLLBACK_REQUEST_KEYS, "credentialSinkRollback", errors);
|
||||
if (!isPlainObject(value.rollback)) {
|
||||
errors.push("rollback_must_be_object");
|
||||
return result(errors);
|
||||
}
|
||||
rejectUnknownKeys(value.rollback, ROLLBACK_KEYS, "rollback", errors);
|
||||
requiredOpaqueId(value.rollback.id, "rollback.id", errors);
|
||||
requiredOpaqueId(value.rollback.idempotencyKey, "rollback.idempotencyKey", errors);
|
||||
requiredOpaqueId(value.rollback.transactionId, "rollback.transactionId", errors);
|
||||
requiredTimestamp(value.rollback.requestedAt, "rollback.requestedAt", errors);
|
||||
requiredTimestamp(value.rollback.requestExpiresAt, "rollback.requestExpiresAt", errors);
|
||||
requiredHash(value.rollback.policyHash, "rollback.policyHash", errors);
|
||||
requiredHash(value.rollback.committedReceiptHash, "rollback.committedReceiptHash", errors);
|
||||
requiredReason(value.rollback.reasonCode, "rollback.reasonCode", errors);
|
||||
validateRequestWindow(value.rollback.requestedAt, value.rollback.requestExpiresAt, nowMs, errors);
|
||||
if (containsSecretLikeMaterial(value)) errors.push("rollback_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEngineCredentialSinkRollbackReceipt(value, { request } = {}) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["credentialSinkRollbackReceipt_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, ROLLBACK_RECEIPT_KEYS, "credentialSinkRollbackReceipt", errors);
|
||||
requiredOpaqueId(value.rollbackId, "rollbackId", errors);
|
||||
requiredOpaqueId(value.transactionId, "transactionId", errors);
|
||||
requiredHash(value.policyHash, "policyHash", errors);
|
||||
requiredHash(value.committedReceiptHash, "committedReceiptHash", errors);
|
||||
requiredTimestamp(value.processedAt, "processedAt", errors);
|
||||
if (!new Set(["rolled-back", "rejected", "rollback-failed"]).has(value.outcome)) errors.push("outcome_invalid");
|
||||
if (value.outcome === "rolled-back") {
|
||||
if (value.errorCode !== undefined) errors.push("errorCode_forbidden_for_success");
|
||||
} else {
|
||||
requiredReason(value.errorCode, "errorCode", errors);
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("rollbackReceipt_must_not_contain_secret_material");
|
||||
if (request !== undefined && isPlainObject(request?.rollback)) {
|
||||
if (!validateEngineCredentialSinkRollback(request, { now: value.processedAt }).ok) {
|
||||
errors.push("request_invalid_for_rollback_receipt_comparison");
|
||||
}
|
||||
if (value.rollbackId !== request.rollback.id) errors.push("rollbackId_request_mismatch");
|
||||
if (value.transactionId !== request.rollback.transactionId) errors.push("transactionId_request_mismatch");
|
||||
if (value.policyHash !== request.rollback.policyHash) errors.push("policyHash_request_mismatch");
|
||||
if (value.committedReceiptHash !== request.rollback.committedReceiptHash) {
|
||||
errors.push("committedReceiptHash_request_mismatch");
|
||||
}
|
||||
}
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEngineCredentialSinkAudit(value) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["credentialSinkAudit_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, AUDIT_KEYS, "credentialSinkAudit", errors);
|
||||
requiredOpaqueId(value.eventId, "eventId", errors);
|
||||
requiredOpaqueId(value.transactionId, "transactionId", errors);
|
||||
requiredOpaqueId(value.operationId, "operationId", errors);
|
||||
if (!new Set(["provision", "rollback"]).has(value.operation)) errors.push("operation_invalid");
|
||||
if (!new Set(["committed", "rolled-back", "rejected", "rollback-failed"]).has(value.outcome)) errors.push("outcome_invalid");
|
||||
if (value.operation === "provision" && value.outcome === "rolled-back" && !value.reasonCode) {
|
||||
errors.push("reasonCode_required");
|
||||
}
|
||||
if (value.operation === "rollback" && value.outcome === "committed") errors.push("rollback_outcome_invalid");
|
||||
requiredTimestamp(value.occurredAt, "occurredAt", errors);
|
||||
requiredHash(value.policyHash, "policyHash", errors);
|
||||
if (!isPlainObject(value.principal)) {
|
||||
errors.push("principal_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.principal, PRINCIPAL_KEYS, "principal", errors);
|
||||
requiredIdentifier(value.principal.serviceId, "principal.serviceId", errors);
|
||||
requiredHash(value.principal.fingerprint, "principal.fingerprint", errors);
|
||||
}
|
||||
if (!Array.isArray(value.targets)) {
|
||||
errors.push("targets_must_be_array");
|
||||
} else {
|
||||
value.targets.forEach((target, index) => validateAuditTarget(target, index, errors));
|
||||
}
|
||||
if (value.reasonCode !== undefined) requiredReason(value.reasonCode, "reasonCode", errors);
|
||||
if (new Set(["rejected", "rollback-failed"]).has(value.outcome) && value.reasonCode === undefined) {
|
||||
errors.push("reasonCode_required");
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("audit_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
/** Returns audit-safe target descriptors; material and credential refs cannot escape. */
|
||||
export function engineCredentialSinkAuditTargets(request, receipt) {
|
||||
const refByBinding = new Map(
|
||||
Array.isArray(receipt?.credentials)
|
||||
? receipt.credentials.map((item) => [item.bindingId, item.credentialRef])
|
||||
: [],
|
||||
);
|
||||
return Array.isArray(request?.bindings) ? request.bindings.map((binding) => {
|
||||
const descriptor = secretFreeBindingDescriptor(binding);
|
||||
const credentialRef = refByBinding.get(binding.bindingId);
|
||||
return credentialRef
|
||||
? { ...descriptor, credentialRefHash: sha256Value(credentialRef) }
|
||||
: descriptor;
|
||||
}) : [];
|
||||
}
|
||||
|
||||
function validateProvisionBinding(value, index, requestedAt, errors) {
|
||||
const path = `bindings[${index}]`;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, BINDING_KEYS, path, errors);
|
||||
requiredIdentifier(value.bindingId, `${path}.bindingId`, errors);
|
||||
const spec = CAPABILITY_SPECS[value.capabilityType];
|
||||
if (!spec) errors.push(`${path}.capabilityType_invalid`);
|
||||
requiredOpaqueId(value.grantId, `${path}.grantId`, errors);
|
||||
validateTarget(value.target, path, errors);
|
||||
requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors);
|
||||
requiredHash(value.policyHash, `${path}.policyHash`, errors);
|
||||
requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors);
|
||||
if (isTimestamp(requestedAt) && isTimestamp(value.expiresAt) && Date.parse(value.expiresAt) <= Date.parse(requestedAt)) {
|
||||
errors.push(`${path}.expiresAt_must_be_after_requestedAt`);
|
||||
}
|
||||
if (spec && isPlainObject(value.target)) {
|
||||
if (value.target.nodeType !== spec.nodeType) errors.push(`${path}.target.nodeType_capability_mismatch`);
|
||||
if (value.target.credentialType !== spec.credentialType) errors.push(`${path}.target.credentialType_capability_mismatch`);
|
||||
}
|
||||
if (!isPlainObject(value.material)) {
|
||||
errors.push(`${path}.material_must_be_object`);
|
||||
} else {
|
||||
rejectUnknownKeys(value.material, MATERIAL_KEYS, `${path}.material`, errors);
|
||||
if (value.material.format !== "opaque-bearer") errors.push(`${path}.material.format_must_be_opaque-bearer`);
|
||||
if (!spec || typeof value.material.value !== "string" || !spec.materialPattern.test(value.material.value)) {
|
||||
errors.push(`${path}.material.value_invalid_for_capability`);
|
||||
} else if (value.capabilityDigest !== computeEngineCredentialCapabilityDigest(value.material.value)) {
|
||||
errors.push(`${path}.capabilityDigest_material_mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTarget(value, path, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}.target_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, TARGET_KEYS, `${path}.target`, errors);
|
||||
requiredOpaqueId(value.workflowId, `${path}.target.workflowId`, errors);
|
||||
requiredOpaqueId(value.workflowRevision, `${path}.target.workflowRevision`, errors);
|
||||
requiredOpaqueId(value.nodeId, `${path}.target.nodeId`, errors);
|
||||
if (typeof value.nodeType !== "string" || !NODE_TYPE.test(value.nodeType)) errors.push(`${path}.target.nodeType_invalid`);
|
||||
if (typeof value.credentialType !== "string" || !CREDENTIAL_TYPE.test(value.credentialType)) {
|
||||
errors.push(`${path}.target.credentialType_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateReceiptCredential(value, index, errors) {
|
||||
const path = `credentials[${index}]`;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, RECEIPT_CREDENTIAL_KEYS, path, errors);
|
||||
requiredIdentifier(value.bindingId, `${path}.bindingId`, errors);
|
||||
if (!CAPABILITY_SPECS[value.capabilityType]) errors.push(`${path}.capabilityType_invalid`);
|
||||
requiredOpaqueId(value.grantId, `${path}.grantId`, errors);
|
||||
validateTarget(value.target, path, errors);
|
||||
if (typeof value.credentialRef !== "string" || !CREDENTIAL_REFERENCE.test(value.credentialRef)) {
|
||||
errors.push(`${path}.credentialRef_invalid`);
|
||||
}
|
||||
requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors);
|
||||
requiredHash(value.policyHash, `${path}.policyHash`, errors);
|
||||
requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors);
|
||||
if (!new Set(["created", "reused", "rotated"]).has(value.disposition)) errors.push(`${path}.disposition_invalid`);
|
||||
}
|
||||
|
||||
function validateReceiptOutcome(value, errors) {
|
||||
if (!isPlainObject(value.rollback)) {
|
||||
errors.push("rollback_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value.rollback, RECEIPT_ROLLBACK_KEYS, "rollback", errors);
|
||||
const expectedRollback = {
|
||||
committed: "not-required",
|
||||
"rolled-back": "complete",
|
||||
rejected: "not-started",
|
||||
"rollback-failed": "incomplete",
|
||||
}[value.outcome];
|
||||
if (expectedRollback && value.rollback.status !== expectedRollback) errors.push("rollback.status_outcome_mismatch");
|
||||
if (new Set(["complete", "incomplete"]).has(value.rollback.status)) {
|
||||
requiredTimestamp(value.rollback.completedAt, "rollback.completedAt", errors);
|
||||
} else if (value.rollback.completedAt !== undefined) {
|
||||
errors.push("rollback.completedAt_not_allowed");
|
||||
}
|
||||
if (value.outcome === "committed") {
|
||||
if (!Array.isArray(value.credentials) || value.credentials.length === 0) errors.push("committed_credentials_required");
|
||||
if (value.errorCode !== undefined) errors.push("errorCode_forbidden_for_success");
|
||||
} else {
|
||||
if (Array.isArray(value.credentials) && value.credentials.length !== 0) errors.push("noncommitted_credentials_must_be_empty");
|
||||
requiredReason(value.errorCode, "errorCode", errors);
|
||||
}
|
||||
}
|
||||
|
||||
function compareReceiptToProvisionRequest(receipt, request, issuerPublicKeys, errors) {
|
||||
const requestValidation = validateEngineCredentialSinkProvision(request, {
|
||||
now: receipt.processedAt,
|
||||
issuerPublicKeys,
|
||||
});
|
||||
if (!requestValidation.ok) {
|
||||
errors.push("request_invalid_for_receipt_comparison");
|
||||
return;
|
||||
}
|
||||
if (receipt.transactionId !== request.transaction.id) errors.push("transactionId_request_mismatch");
|
||||
if (receipt.idempotencyKey !== request.transaction.idempotencyKey) errors.push("idempotencyKey_request_mismatch");
|
||||
if (receipt.policyHash !== request.transaction.policyHash) errors.push("policyHash_request_mismatch");
|
||||
if (receipt.outcome !== "committed") return;
|
||||
if (receipt.credentials.length !== request.bindings.length) errors.push("credentials_request_count_mismatch");
|
||||
const requested = new Map(request.bindings.map((binding) => [binding.bindingId, secretFreeBindingDescriptor(binding)]));
|
||||
for (const credential of receipt.credentials) {
|
||||
const expected = requested.get(credential.bindingId);
|
||||
if (!expected || stableJson({
|
||||
bindingId: credential.bindingId,
|
||||
capabilityType: credential.capabilityType,
|
||||
grantId: credential.grantId,
|
||||
target: credential.target,
|
||||
expiresAt: credential.expiresAt,
|
||||
policyHash: credential.policyHash,
|
||||
capabilityDigest: credential.capabilityDigest,
|
||||
}) !== stableJson(expected)) {
|
||||
errors.push("credentials_request_target_mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuditTarget(value, index, errors) {
|
||||
const path = `targets[${index}]`;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, AUDIT_TARGET_KEYS, path, errors);
|
||||
requiredIdentifier(value.bindingId, `${path}.bindingId`, errors);
|
||||
if (!CAPABILITY_SPECS[value.capabilityType]) errors.push(`${path}.capabilityType_invalid`);
|
||||
requiredOpaqueId(value.grantId, `${path}.grantId`, errors);
|
||||
validateTarget(value.target, path, errors);
|
||||
requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors);
|
||||
requiredHash(value.policyHash, `${path}.policyHash`, errors);
|
||||
requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors);
|
||||
if (value.credentialRefHash !== undefined) requiredHash(value.credentialRefHash, `${path}.credentialRefHash`, errors);
|
||||
}
|
||||
|
||||
function secretFreeBindingDescriptor(value) {
|
||||
return {
|
||||
bindingId: value?.bindingId,
|
||||
capabilityType: value?.capabilityType,
|
||||
grantId: value?.grantId,
|
||||
target: value?.target,
|
||||
expiresAt: value?.expiresAt,
|
||||
policyHash: value?.policyHash,
|
||||
capabilityDigest: value?.capabilityDigest,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeEngineCredentialCapabilityDigest(value) {
|
||||
return sha256Value(value);
|
||||
}
|
||||
|
||||
function validateIssuer(value, path, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, ISSUER_KEYS, path, errors);
|
||||
requiredIdentifier(value.serviceId, `${path}.serviceId`, errors);
|
||||
requiredOpaqueId(value.keyId, `${path}.keyId`, errors);
|
||||
}
|
||||
|
||||
function validateAttestation(value, path, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, ATTESTATION_KEYS, path, errors);
|
||||
if (value.algorithm !== "Ed25519") errors.push(`${path}.algorithm_must_be_Ed25519`);
|
||||
if (typeof value.signature !== "string" || !ED25519_SIGNATURE.test(value.signature)) {
|
||||
errors.push(`${path}.signature_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyProvisionAttestation(transaction, issuerPublicKeys, errors) {
|
||||
if (!isPlainObject(transaction?.issuer) || !isPlainObject(transaction?.attestation)) return;
|
||||
if (transaction.attestation.algorithm !== "Ed25519" || !ED25519_SIGNATURE.test(String(transaction.attestation.signature || ""))) return;
|
||||
const keyIdentity = `${transaction.issuer.serviceId}:${transaction.issuer.keyId}`;
|
||||
const publicKey = isPlainObject(issuerPublicKeys) && Object.hasOwn(issuerPublicKeys, keyIdentity)
|
||||
? issuerPublicKeys[keyIdentity]
|
||||
: undefined;
|
||||
if (!publicKey) {
|
||||
errors.push("transaction.issuer_public_key_required");
|
||||
return;
|
||||
}
|
||||
let valid = false;
|
||||
try {
|
||||
valid = verifySignature(
|
||||
null,
|
||||
Buffer.from(String(transaction.policyHash), "utf8"),
|
||||
publicKey,
|
||||
Buffer.from(transaction.attestation.signature, "base64url"),
|
||||
);
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) errors.push("transaction.attestation_invalid");
|
||||
}
|
||||
|
||||
function validateRequestWindow(requestedAt, requestExpiresAt, nowMs, errors) {
|
||||
if (!isTimestamp(requestedAt) || !isTimestamp(requestExpiresAt)) return;
|
||||
const requested = Date.parse(requestedAt);
|
||||
const expires = Date.parse(requestExpiresAt);
|
||||
if (expires <= requested) errors.push("requestExpiresAt_must_be_after_requestedAt");
|
||||
if (expires - requested > MAX_REQUEST_LIFETIME_MS) errors.push("request_lifetime_exceeds_15_minutes");
|
||||
if (Number.isFinite(nowMs)) {
|
||||
if (requested > nowMs + MAX_REQUEST_CLOCK_SKEW_MS) errors.push("requestedAt_exceeds_clock_skew");
|
||||
if (expires <= nowMs) errors.push("request_expired");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNow(value, errors) {
|
||||
const normalized = value instanceof Date ? value.getTime() : typeof value === "string" ? Date.parse(value) : Number(value);
|
||||
if (!Number.isFinite(normalized)) {
|
||||
errors.push("validation_now_invalid");
|
||||
return Number.NaN;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function targetIdentity(value) {
|
||||
if (!isPlainObject(value)) return "";
|
||||
return [value.workflowId, value.workflowRevision, value.nodeId, value.nodeType, value.credentialType].join("\u0000");
|
||||
}
|
||||
|
||||
function requiredIdentifier(value, path, errors) {
|
||||
if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredOpaqueId(value, path, errors) {
|
||||
if (typeof value !== "string" || !OPAQUE_ID.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredHash(value, path, errors) {
|
||||
if (typeof value !== "string" || !HASH.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredReason(value, path, errors) {
|
||||
if (typeof value !== "string" || !REASON_CODE.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function requiredTimestamp(value, path, errors) {
|
||||
if (!isTimestamp(value)) errors.push(`${path}_invalid_timestamp`);
|
||||
}
|
||||
|
||||
function isTimestamp(value) {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
|
||||
}
|
||||
|
||||
function sha256Value(value) {
|
||||
return `sha256:${createHash("sha256").update(String(value), "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortValue(value));
|
||||
}
|
||||
|
||||
function sortValue(value) {
|
||||
if (Array.isArray(value)) return value.map(sortValue);
|
||||
if (!isPlainObject(value)) return value;
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
||||
}
|
||||
|
||||
function containsSecretLikeMaterial(value) {
|
||||
if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
|
||||
if (Array.isArray(value)) return value.some(containsSecretLikeMaterial);
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowed, path, errors) {
|
||||
if (!isPlainObject(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function result(errors) {
|
||||
const uniqueErrors = [...new Set(errors)];
|
||||
return Object.freeze({ ok: uniqueErrors.length === 0, errors: Object.freeze(uniqueErrors) });
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.plan-request/v1";
|
||||
export const ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.plan/v1";
|
||||
export const ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.apply-request/v1";
|
||||
export const ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.apply-receipt/v1";
|
||||
export const ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.operation/v1";
|
||||
export const ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION =
|
||||
"nodedc.engine.private-extension.status/v1";
|
||||
|
||||
export const ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY = "engine.private-extension.manage";
|
||||
export const ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY = "engine.private-extension.read";
|
||||
export const ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME = "n8n-nodes-ndc";
|
||||
export const ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE = "n8n-nodes-ndc.inactive/v1";
|
||||
|
||||
export const ENGINE_PRIVATE_EXTENSION_NODE_TYPES = Object.freeze([
|
||||
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||
"n8n-nodes-ndc.ndcDataProductRead",
|
||||
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||
]);
|
||||
|
||||
export const ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES = Object.freeze([
|
||||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
]);
|
||||
export const ENGINE_PRIVATE_EXTENSION_CREDENTIAL_SCHEMAS = ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES;
|
||||
|
||||
const PLAN_REQUEST_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"requestId",
|
||||
"idempotencyKey",
|
||||
"action",
|
||||
"requestedAt",
|
||||
"requestExpiresAt",
|
||||
"expectedCurrentGeneration",
|
||||
"target",
|
||||
]);
|
||||
const RELEASE_STATE_KEYS = new Set(["kind", "packageName", "releaseId", "packageSha256"]);
|
||||
const INACTIVE_STATE_KEYS = new Set(["kind", "packageName", "baselineId"]);
|
||||
const PREVIOUS_STATE_TARGET_KEYS = new Set(["kind", "packageName"]);
|
||||
const PLAN_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"planId",
|
||||
"planHash",
|
||||
"requestId",
|
||||
"idempotencyKey",
|
||||
"action",
|
||||
"createdAt",
|
||||
"expiresAt",
|
||||
"singleUse",
|
||||
"requiredCapability",
|
||||
"expectedCurrentGeneration",
|
||||
"nextGeneration",
|
||||
"currentState",
|
||||
"targetState",
|
||||
"recoveryState",
|
||||
"actions",
|
||||
"transition",
|
||||
"acceptance",
|
||||
"failurePolicy",
|
||||
]);
|
||||
const TRANSITION_KEYS = new Set([
|
||||
"mountMode",
|
||||
"loaderMode",
|
||||
"loaderPath",
|
||||
"loaderEnvironment",
|
||||
"quiesceMode",
|
||||
"stateSwitch",
|
||||
"runtimeAction",
|
||||
"scope",
|
||||
"requireUniformGeneration",
|
||||
"hotReload",
|
||||
"preserveCredentials",
|
||||
]);
|
||||
const LOADER_ENVIRONMENT_KEYS = new Set([
|
||||
"N8N_COMMUNITY_PACKAGES_ENABLED",
|
||||
"N8N_COMMUNITY_PACKAGES_PREVENT_LOADING",
|
||||
"N8N_REINSTALL_MISSING_PACKAGES",
|
||||
]);
|
||||
const ACCEPTANCE_SPEC_KEYS = new Set([
|
||||
"mode",
|
||||
"nodeTypes",
|
||||
"credentialSchemas",
|
||||
"requireUniformGeneration",
|
||||
]);
|
||||
const FAILURE_POLICY_KEYS = new Set([
|
||||
"mode",
|
||||
"rollbackFailureOutcome",
|
||||
"preserveImmutableRelease",
|
||||
"preserveCredentials",
|
||||
]);
|
||||
const APPLY_REQUEST_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"planId",
|
||||
"planHash",
|
||||
"idempotencyKey",
|
||||
"confirmedAt",
|
||||
]);
|
||||
const APPLY_RECEIPT_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"operationId",
|
||||
"planId",
|
||||
"planHash",
|
||||
"action",
|
||||
"acceptedAt",
|
||||
"state",
|
||||
]);
|
||||
const OPERATION_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"operationId",
|
||||
"planId",
|
||||
"planHash",
|
||||
"action",
|
||||
"state",
|
||||
"outcome",
|
||||
"phase",
|
||||
"expectedCurrentGeneration",
|
||||
"nextGeneration",
|
||||
"targetState",
|
||||
"recoveryState",
|
||||
"effectiveState",
|
||||
"runtime",
|
||||
"acceptance",
|
||||
"updatedAt",
|
||||
"errorCode",
|
||||
]);
|
||||
const RUNTIME_KEYS = new Set(["mode", "expectedInstances", "readyInstances", "generation"]);
|
||||
const ACCEPTANCE_REPORT_KEYS = new Set([
|
||||
"state",
|
||||
"nodeTypes",
|
||||
"credentialSchemas",
|
||||
"uniformGeneration",
|
||||
]);
|
||||
const OBSERVATION_KEYS = new Set(["expected", "observed"]);
|
||||
const STATUS_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"packageName",
|
||||
"generation",
|
||||
"health",
|
||||
"currentState",
|
||||
"previousState",
|
||||
"activeOperationId",
|
||||
"runtime",
|
||||
"acceptance",
|
||||
"updatedAt",
|
||||
"errorCode",
|
||||
]);
|
||||
|
||||
const RELEASE_ID = /^\d+\.\d+\.\d+-[a-f0-9]{16}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const HASH = /^sha256:[a-f0-9]{64}$/;
|
||||
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,159}$/;
|
||||
const REASON_CODE = /^[a-z][a-z0-9_.:-]{2,127}$/;
|
||||
const MAX_REQUEST_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const MAX_CLOCK_SKEW_MS = 60 * 1000;
|
||||
|
||||
const ACTIVATE_ACTIONS = Object.freeze([
|
||||
"verify_staged_immutable_release",
|
||||
"prepare_sealed_package_tree",
|
||||
"verify_community_package_loader_policy",
|
||||
"quiesce_deploy_run_and_drain_queue",
|
||||
"record_recovery_state",
|
||||
"atomic_switch_current",
|
||||
"force_recreate_main_workers_webhooks_as_version_barrier",
|
||||
"verify_exact_runtime_acceptance",
|
||||
"commit_active_state",
|
||||
"resume_deploy_run",
|
||||
]);
|
||||
const ROLLBACK_ACTIONS = Object.freeze([
|
||||
"verify_previous_activation_state",
|
||||
"verify_community_package_loader_policy",
|
||||
"quiesce_deploy_run_and_drain_queue",
|
||||
"record_recovery_state",
|
||||
"atomic_switch_current_to_previous",
|
||||
"force_recreate_main_workers_webhooks_as_version_barrier",
|
||||
"verify_exact_runtime_acceptance",
|
||||
"commit_rolled_back_state",
|
||||
"resume_deploy_run",
|
||||
]);
|
||||
|
||||
const NON_TERMINAL_STATES = new Set([
|
||||
"queued",
|
||||
"preparing",
|
||||
"switching",
|
||||
"recreating",
|
||||
"accepting",
|
||||
"rolling-back",
|
||||
]);
|
||||
const TERMINAL_STATE_OUTCOMES = Object.freeze({
|
||||
active: "committed",
|
||||
rejected: "rejected",
|
||||
quarantined: "quarantined",
|
||||
});
|
||||
|
||||
export function authorizeEnginePrivateExtensionOperation(operation, grantedCapabilities) {
|
||||
const capabilities = new Set(Array.isArray(grantedCapabilities) ? grantedCapabilities : []);
|
||||
const requiredCapability = operation === "status"
|
||||
? ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY
|
||||
: ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY;
|
||||
const authorized = operation === "status"
|
||||
? capabilities.has(ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY)
|
||||
|| capabilities.has(ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY)
|
||||
: capabilities.has(ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY);
|
||||
return Object.freeze({
|
||||
ok: authorized,
|
||||
requiredCapability,
|
||||
errors: Object.freeze(authorized ? [] : ["engine_private_extension_capability_required"]),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionPlanRequest(value, options = {}) {
|
||||
const errors = [];
|
||||
validatePlanRequestBody(value, options.now, errors);
|
||||
addAuthorizationErrors("plan", options.grantedCapabilities, errors);
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function computeEnginePrivateExtensionPlanHash(value) {
|
||||
if (!isPlainObject(value)) return "";
|
||||
const descriptor = { ...value };
|
||||
delete descriptor.planHash;
|
||||
return "sha256:" + createHash("sha256").update(stableJson(descriptor), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionPlan(value, { request } = {}) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["enginePrivateExtensionPlan_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, PLAN_KEYS, "enginePrivateExtensionPlan", errors);
|
||||
requiredOpaqueId(value.planId, "planId", errors);
|
||||
requiredHash(value.planHash, "planHash", errors);
|
||||
requiredOpaqueId(value.requestId, "requestId", errors);
|
||||
requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors);
|
||||
if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid");
|
||||
requiredTimestamp(value.createdAt, "createdAt", errors);
|
||||
requiredTimestamp(value.expiresAt, "expiresAt", errors);
|
||||
validateWindow(value.createdAt, value.expiresAt, Date.parse(value.createdAt), errors, "plan");
|
||||
if (value.singleUse !== true) errors.push("singleUse_must_be_true");
|
||||
if (value.requiredCapability !== ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY) {
|
||||
errors.push("requiredCapability_mismatch");
|
||||
}
|
||||
requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors);
|
||||
requiredGeneration(value.nextGeneration, "nextGeneration", errors);
|
||||
if (Number.isInteger(value.expectedCurrentGeneration)
|
||||
&& value.nextGeneration !== value.expectedCurrentGeneration + 1) {
|
||||
errors.push("nextGeneration_must_increment_current_generation");
|
||||
}
|
||||
validateActivationState(value.currentState, "currentState", errors);
|
||||
validateActivationState(value.targetState, "targetState", errors);
|
||||
validateActivationState(value.recoveryState, "recoveryState", errors);
|
||||
if (!sameValue(value.currentState, value.recoveryState)) errors.push("recoveryState_must_equal_currentState");
|
||||
if (value.action === "activate" && value.targetState?.kind !== "release") {
|
||||
errors.push("activate_targetState_must_be_release");
|
||||
}
|
||||
if (sameValue(value.currentState, value.targetState)) errors.push("targetState_must_differ_from_currentState");
|
||||
validateExactArray(
|
||||
value.actions,
|
||||
value.action === "rollback" ? ROLLBACK_ACTIONS : ACTIVATE_ACTIONS,
|
||||
"actions",
|
||||
errors,
|
||||
);
|
||||
validateTransition(value.transition, errors);
|
||||
validateAcceptanceSpec(value.acceptance, value.targetState, errors);
|
||||
validateFailurePolicy(value.failurePolicy, errors);
|
||||
if (HASH.test(String(value.planHash || "")) && value.planHash !== computeEnginePrivateExtensionPlanHash(value)) {
|
||||
errors.push("planHash_mismatch");
|
||||
}
|
||||
if (request !== undefined) comparePlanToRequest(value, request, errors);
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionApplyRequest(value, { plan, now = Date.now(), grantedCapabilities } = {}) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["enginePrivateExtensionApplyRequest_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, APPLY_REQUEST_KEYS, "enginePrivateExtensionApplyRequest", errors);
|
||||
requiredOpaqueId(value.planId, "planId", errors);
|
||||
requiredHash(value.planHash, "planHash", errors);
|
||||
requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors);
|
||||
requiredTimestamp(value.confirmedAt, "confirmedAt", errors);
|
||||
addAuthorizationErrors("apply", grantedCapabilities, errors);
|
||||
const nowMs = normalizeNow(now, errors);
|
||||
if (isTimestamp(value.confirmedAt) && Date.parse(value.confirmedAt) > nowMs + MAX_CLOCK_SKEW_MS) {
|
||||
errors.push("confirmedAt_exceeds_clock_skew");
|
||||
}
|
||||
if (plan !== undefined) {
|
||||
const planValidation = validateEnginePrivateExtensionPlan(plan);
|
||||
if (!planValidation.ok) errors.push("plan_invalid_for_apply");
|
||||
if (value.planId !== plan?.planId) errors.push("planId_plan_mismatch");
|
||||
if (value.planHash !== plan?.planHash) errors.push("planHash_plan_mismatch");
|
||||
if (value.idempotencyKey !== plan?.idempotencyKey) errors.push("idempotencyKey_plan_mismatch");
|
||||
if (isTimestamp(plan?.expiresAt) && nowMs > Date.parse(plan.expiresAt)) errors.push("plan_expired");
|
||||
if (isTimestamp(value.confirmedAt) && isTimestamp(plan?.expiresAt)
|
||||
&& Date.parse(value.confirmedAt) > Date.parse(plan.expiresAt)) {
|
||||
errors.push("confirmedAt_after_plan_expiresAt");
|
||||
}
|
||||
if (isTimestamp(value.confirmedAt) && isTimestamp(plan?.createdAt)
|
||||
&& Date.parse(value.confirmedAt) < Date.parse(plan.createdAt)) {
|
||||
errors.push("confirmedAt_before_plan_createdAt");
|
||||
}
|
||||
}
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionApplyReceipt(value, { plan } = {}) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["enginePrivateExtensionApplyReceipt_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, APPLY_RECEIPT_KEYS, "enginePrivateExtensionApplyReceipt", errors);
|
||||
requiredOpaqueId(value.operationId, "operationId", errors);
|
||||
requiredOpaqueId(value.planId, "planId", errors);
|
||||
requiredHash(value.planHash, "planHash", errors);
|
||||
if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid");
|
||||
requiredTimestamp(value.acceptedAt, "acceptedAt", errors);
|
||||
if (value.state !== "queued") errors.push("apply_receipt_state_must_be_queued");
|
||||
if (plan !== undefined) {
|
||||
if (value.planId !== plan?.planId) errors.push("planId_plan_mismatch");
|
||||
if (value.planHash !== plan?.planHash) errors.push("planHash_plan_mismatch");
|
||||
if (value.action !== plan?.action) errors.push("action_plan_mismatch");
|
||||
if (isTimestamp(value.acceptedAt) && isTimestamp(plan?.expiresAt)
|
||||
&& Date.parse(value.acceptedAt) > Date.parse(plan.expiresAt)) {
|
||||
errors.push("acceptedAt_after_plan_expiresAt");
|
||||
}
|
||||
}
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionOperation(value) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["enginePrivateExtensionOperation_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, OPERATION_KEYS, "enginePrivateExtensionOperation", errors);
|
||||
requiredOpaqueId(value.operationId, "operationId", errors);
|
||||
requiredOpaqueId(value.planId, "planId", errors);
|
||||
requiredHash(value.planHash, "planHash", errors);
|
||||
if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid");
|
||||
if (!new Set([...NON_TERMINAL_STATES, "active", "rolled-back", "rejected", "quarantined"]).has(value.state)) {
|
||||
errors.push("state_invalid");
|
||||
}
|
||||
if (!new Set([
|
||||
"pending",
|
||||
"committed",
|
||||
"automatically-rolled-back",
|
||||
"explicitly-rolled-back",
|
||||
"rejected",
|
||||
"quarantined",
|
||||
]).has(value.outcome)) errors.push("outcome_invalid");
|
||||
if (!new Set([
|
||||
"queued",
|
||||
"prepare",
|
||||
"switch",
|
||||
"force-recreate",
|
||||
"acceptance",
|
||||
"rollback",
|
||||
"complete",
|
||||
]).has(value.phase)) errors.push("phase_invalid");
|
||||
requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors);
|
||||
requiredGeneration(value.nextGeneration, "nextGeneration", errors);
|
||||
if (Number.isInteger(value.expectedCurrentGeneration)
|
||||
&& value.nextGeneration !== value.expectedCurrentGeneration + 1) {
|
||||
errors.push("nextGeneration_must_increment_current_generation");
|
||||
}
|
||||
validateActivationState(value.targetState, "targetState", errors);
|
||||
validateActivationState(value.recoveryState, "recoveryState", errors);
|
||||
validateActivationState(value.effectiveState, "effectiveState", errors);
|
||||
if (sameValue(value.targetState, value.recoveryState)) errors.push("targetState_must_differ_from_recoveryState");
|
||||
validateRuntimeReport(value.runtime, errors);
|
||||
validateAcceptanceReport(value.acceptance, value.effectiveState, errors);
|
||||
requiredTimestamp(value.updatedAt, "updatedAt", errors);
|
||||
validateOperationOutcome(value, errors);
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateEnginePrivateExtensionStatus(value) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["enginePrivateExtensionStatus_must_be_object"]);
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, STATUS_KEYS, "enginePrivateExtensionStatus", errors);
|
||||
if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push("packageName_mismatch");
|
||||
requiredGeneration(value.generation, "generation", errors);
|
||||
if (!new Set(["ready", "transitioning", "quarantined"]).has(value.health)) errors.push("health_invalid");
|
||||
validateActivationState(value.currentState, "currentState", errors);
|
||||
validateActivationState(value.previousState, "previousState", errors);
|
||||
if (value.activeOperationId !== undefined) requiredOpaqueId(value.activeOperationId, "activeOperationId", errors);
|
||||
validateRuntimeReport(value.runtime, errors);
|
||||
validateAcceptanceReport(value.acceptance, value.currentState, errors);
|
||||
requiredTimestamp(value.updatedAt, "updatedAt", errors);
|
||||
if (value.runtime?.generation !== value.generation) errors.push("runtime_generation_mismatch");
|
||||
if (value.health === "ready") {
|
||||
if (value.activeOperationId !== undefined) errors.push("ready_status_must_not_have_active_operation");
|
||||
if (value.acceptance?.state !== "accepted") errors.push("ready_status_requires_acceptance");
|
||||
if (value.errorCode !== undefined) errors.push("ready_status_must_not_have_errorCode");
|
||||
} else if (value.health === "transitioning") {
|
||||
if (value.activeOperationId === undefined) errors.push("transitioning_status_requires_active_operation");
|
||||
} else {
|
||||
requiredReason(value.errorCode, "errorCode", errors);
|
||||
}
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
function validatePlanRequestBody(value, now, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("enginePrivateExtensionPlanRequest_must_be_object");
|
||||
return;
|
||||
}
|
||||
if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, PLAN_REQUEST_KEYS, "enginePrivateExtensionPlanRequest", errors);
|
||||
requiredOpaqueId(value.requestId, "requestId", errors);
|
||||
requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors);
|
||||
if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid");
|
||||
requiredTimestamp(value.requestedAt, "requestedAt", errors);
|
||||
requiredTimestamp(value.requestExpiresAt, "requestExpiresAt", errors);
|
||||
requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors);
|
||||
validateWindow(value.requestedAt, value.requestExpiresAt, normalizeNow(now, errors), errors, "request");
|
||||
validateRequestTarget(value.target, value.action, errors);
|
||||
}
|
||||
|
||||
function validateRequestTarget(value, action, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("target_must_be_object");
|
||||
return;
|
||||
}
|
||||
if (action === "activate") {
|
||||
validateActivationState(value, "target", errors);
|
||||
if (value.kind !== "release") errors.push("activate_target_must_be_release");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, PREVIOUS_STATE_TARGET_KEYS, "target", errors);
|
||||
if (value.kind !== "previous-state") errors.push("rollback_target_must_be_previous-state");
|
||||
if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push("target.packageName_mismatch");
|
||||
}
|
||||
|
||||
function validateActivationState(value, path, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(path + "_must_be_object");
|
||||
return;
|
||||
}
|
||||
if (value.kind === "release") {
|
||||
rejectUnknownKeys(value, RELEASE_STATE_KEYS, path, errors);
|
||||
if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push(path + ".packageName_mismatch");
|
||||
if (typeof value.releaseId !== "string" || !RELEASE_ID.test(value.releaseId)) {
|
||||
errors.push(path + ".releaseId_invalid");
|
||||
}
|
||||
if (typeof value.packageSha256 !== "string" || !SHA256.test(value.packageSha256)) {
|
||||
errors.push(path + ".packageSha256_invalid");
|
||||
} else if (typeof value.releaseId === "string"
|
||||
&& RELEASE_ID.test(value.releaseId)
|
||||
&& !value.releaseId.endsWith("-" + value.packageSha256.slice(0, 16))) {
|
||||
errors.push(path + ".releaseId_digest_mismatch");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.kind === "inactive-baseline") {
|
||||
rejectUnknownKeys(value, INACTIVE_STATE_KEYS, path, errors);
|
||||
if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push(path + ".packageName_mismatch");
|
||||
if (value.baselineId !== ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE) errors.push(path + ".baselineId_mismatch");
|
||||
return;
|
||||
}
|
||||
errors.push(path + ".kind_invalid");
|
||||
}
|
||||
|
||||
function validateTransition(value, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("transition_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, TRANSITION_KEYS, "transition", errors);
|
||||
const expected = {
|
||||
mountMode: "read-only",
|
||||
loaderMode: "community-package",
|
||||
loaderPath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
|
||||
loaderEnvironment: {
|
||||
N8N_COMMUNITY_PACKAGES_ENABLED: "true",
|
||||
N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: "false",
|
||||
N8N_REINSTALL_MISSING_PACKAGES: "false",
|
||||
},
|
||||
quiesceMode: "block-deploy-run-and-drain-queue",
|
||||
stateSwitch: "atomic-current-previous",
|
||||
runtimeAction: "force-recreate",
|
||||
scope: "main-workers-webhooks",
|
||||
requireUniformGeneration: true,
|
||||
hotReload: false,
|
||||
preserveCredentials: true,
|
||||
};
|
||||
if (isPlainObject(value.loaderEnvironment)) {
|
||||
rejectUnknownKeys(value.loaderEnvironment, LOADER_ENVIRONMENT_KEYS, "transition.loaderEnvironment", errors);
|
||||
}
|
||||
if (!sameValue(value, expected)) errors.push("transition_policy_mismatch");
|
||||
}
|
||||
|
||||
function validateAcceptanceSpec(value, targetState, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("acceptance_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, ACCEPTANCE_SPEC_KEYS, "acceptance", errors);
|
||||
if (value.mode !== "exact") errors.push("acceptance.mode_must_be_exact");
|
||||
const expectedNodes = expectedTypes(targetState, ENGINE_PRIVATE_EXTENSION_NODE_TYPES);
|
||||
const expectedCredentials = expectedTypes(targetState, ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES);
|
||||
validateExactArray(value.nodeTypes, expectedNodes, "acceptance.nodeTypes", errors);
|
||||
validateExactArray(value.credentialSchemas, expectedCredentials, "acceptance.credentialSchemas", errors);
|
||||
if (value.requireUniformGeneration !== true) errors.push("acceptance.requireUniformGeneration_must_be_true");
|
||||
}
|
||||
|
||||
function validateFailurePolicy(value, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("failurePolicy_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, FAILURE_POLICY_KEYS, "failurePolicy", errors);
|
||||
const expected = {
|
||||
mode: "automatic-rollback",
|
||||
rollbackFailureOutcome: "quarantined",
|
||||
preserveImmutableRelease: true,
|
||||
preserveCredentials: true,
|
||||
};
|
||||
if (!sameValue(value, expected)) errors.push("failurePolicy_mismatch");
|
||||
}
|
||||
|
||||
function validateRuntimeReport(value, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("runtime_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, RUNTIME_KEYS, "runtime", errors);
|
||||
if (value.mode !== "force-recreate") errors.push("runtime.mode_must_be_force-recreate");
|
||||
requiredPositiveInteger(value.expectedInstances, "runtime.expectedInstances", errors);
|
||||
requiredNonnegativeInteger(value.readyInstances, "runtime.readyInstances", errors);
|
||||
if (Number.isInteger(value.expectedInstances) && Number.isInteger(value.readyInstances)
|
||||
&& value.readyInstances > value.expectedInstances) errors.push("runtime.readyInstances_exceeds_expectedInstances");
|
||||
requiredGeneration(value.generation, "runtime.generation", errors);
|
||||
}
|
||||
|
||||
function validateAcceptanceReport(value, effectiveState, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("acceptance_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, ACCEPTANCE_REPORT_KEYS, "acceptance", errors);
|
||||
if (!new Set(["pending", "accepted", "rejected"]).has(value.state)) errors.push("acceptance.state_invalid");
|
||||
const expectedNodes = expectedTypes(effectiveState, ENGINE_PRIVATE_EXTENSION_NODE_TYPES);
|
||||
const expectedCredentials = expectedTypes(effectiveState, ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES);
|
||||
validateObservation(value.nodeTypes, expectedNodes, "acceptance.nodeTypes", value.state, errors);
|
||||
validateObservation(value.credentialSchemas, expectedCredentials, "acceptance.credentialSchemas", value.state, errors);
|
||||
if (typeof value.uniformGeneration !== "boolean") errors.push("acceptance.uniformGeneration_must_be_boolean");
|
||||
if (value.state === "accepted" && value.uniformGeneration !== true) {
|
||||
errors.push("accepted_runtime_requires_uniform_generation");
|
||||
}
|
||||
}
|
||||
|
||||
function validateObservation(value, expected, path, state, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(path + "_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, OBSERVATION_KEYS, path, errors);
|
||||
validateExactArray(value.expected, expected, path + ".expected", errors);
|
||||
if (!Array.isArray(value.observed) || value.observed.some((item) => typeof item !== "string")) {
|
||||
errors.push(path + ".observed_must_be_string_array");
|
||||
return;
|
||||
}
|
||||
if (new Set(value.observed).size !== value.observed.length) errors.push(path + ".observed_must_be_unique");
|
||||
if (state === "accepted" && !sameValue(value.observed, expected)) errors.push(path + ".observed_exact_set_required");
|
||||
}
|
||||
|
||||
function validateOperationOutcome(value, errors) {
|
||||
if (NON_TERMINAL_STATES.has(value.state)) {
|
||||
if (value.outcome !== "pending") errors.push("nonterminal_operation_outcome_must_be_pending");
|
||||
return;
|
||||
}
|
||||
if (value.state === "rolled-back") {
|
||||
const expected = value.action === "rollback" ? "explicitly-rolled-back" : "automatically-rolled-back";
|
||||
if (value.outcome !== expected) errors.push("rolled_back_outcome_mismatch");
|
||||
} else if (TERMINAL_STATE_OUTCOMES[value.state] !== value.outcome) {
|
||||
errors.push("terminal_operation_outcome_mismatch");
|
||||
}
|
||||
if (value.state === "active" || value.state === "rolled-back") {
|
||||
if (value.acceptance?.state !== "accepted") errors.push("successful_terminal_state_requires_acceptance");
|
||||
if (value.runtime?.readyInstances !== value.runtime?.expectedInstances) {
|
||||
errors.push("successful_terminal_state_requires_all_instances_ready");
|
||||
}
|
||||
if (value.runtime?.generation !== value.nextGeneration) {
|
||||
errors.push("successful_terminal_state_runtime_generation_mismatch");
|
||||
}
|
||||
if (value.errorCode !== undefined && value.state === "active") errors.push("active_state_must_not_have_errorCode");
|
||||
if (value.state === "active" && value.action !== "activate") errors.push("active_state_action_mismatch");
|
||||
const expectedEffective = value.state === "active"
|
||||
? value.targetState
|
||||
: value.action === "rollback" ? value.targetState : value.recoveryState;
|
||||
if (!sameValue(value.effectiveState, expectedEffective)) errors.push("effectiveState_terminal_mismatch");
|
||||
if (value.state === "rolled-back" && value.action === "activate") {
|
||||
requiredReason(value.errorCode, "errorCode", errors);
|
||||
}
|
||||
if (value.state === "rolled-back" && value.action === "rollback" && value.errorCode !== undefined) {
|
||||
errors.push("explicit_rollback_must_not_have_errorCode");
|
||||
}
|
||||
} else if (value.state === "rejected" || value.state === "quarantined") {
|
||||
requiredReason(value.errorCode, "errorCode", errors);
|
||||
}
|
||||
}
|
||||
|
||||
function comparePlanToRequest(plan, request, errors) {
|
||||
const requestErrors = [];
|
||||
validatePlanRequestBody(request, plan.createdAt, requestErrors);
|
||||
if (requestErrors.length) errors.push("request_invalid_for_plan_comparison");
|
||||
if (plan.requestId !== request?.requestId) errors.push("requestId_request_mismatch");
|
||||
if (plan.idempotencyKey !== request?.idempotencyKey) errors.push("idempotencyKey_request_mismatch");
|
||||
if (plan.action !== request?.action) errors.push("action_request_mismatch");
|
||||
if (plan.expectedCurrentGeneration !== request?.expectedCurrentGeneration) {
|
||||
errors.push("expectedCurrentGeneration_request_mismatch");
|
||||
}
|
||||
if (request?.action === "activate" && !sameValue(plan.targetState, request?.target)) {
|
||||
errors.push("targetState_request_mismatch");
|
||||
}
|
||||
if (isTimestamp(request?.requestExpiresAt) && isTimestamp(plan.expiresAt)
|
||||
&& Date.parse(plan.expiresAt) > Date.parse(request.requestExpiresAt)) {
|
||||
errors.push("plan_expiresAt_exceeds_request");
|
||||
}
|
||||
}
|
||||
|
||||
function addAuthorizationErrors(operation, grantedCapabilities, errors) {
|
||||
errors.push(...authorizeEnginePrivateExtensionOperation(operation, grantedCapabilities).errors);
|
||||
}
|
||||
|
||||
function validateWindow(start, end, nowMs, errors, label) {
|
||||
if (!isTimestamp(start) || !isTimestamp(end) || !Number.isFinite(nowMs)) return;
|
||||
const startMs = Date.parse(start);
|
||||
const endMs = Date.parse(end);
|
||||
if (endMs <= startMs) errors.push(label + "_expiresAt_must_be_after_start");
|
||||
if (endMs - startMs > MAX_REQUEST_LIFETIME_MS) errors.push(label + "_lifetime_exceeds_15_minutes");
|
||||
if (startMs > nowMs + MAX_CLOCK_SKEW_MS) errors.push(label + "_start_exceeds_clock_skew");
|
||||
if (endMs < nowMs) errors.push(label + "_expired");
|
||||
}
|
||||
|
||||
function normalizeNow(value, errors) {
|
||||
const candidate = value === undefined ? Date.now() : value;
|
||||
const milliseconds = typeof candidate === "number" ? candidate : Date.parse(candidate);
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
errors.push("now_invalid");
|
||||
return Number.NaN;
|
||||
}
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
function requiredGeneration(value, path, errors) {
|
||||
requiredNonnegativeInteger(value, path, errors);
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(value, path, errors) {
|
||||
if (!Number.isInteger(value) || value < 1) errors.push(path + "_must_be_positive_integer");
|
||||
}
|
||||
|
||||
function requiredNonnegativeInteger(value, path, errors) {
|
||||
if (!Number.isInteger(value) || value < 0) errors.push(path + "_must_be_nonnegative_integer");
|
||||
}
|
||||
|
||||
function requiredOpaqueId(value, path, errors) {
|
||||
if (typeof value !== "string" || !OPAQUE_ID.test(value)) errors.push(path + "_invalid");
|
||||
}
|
||||
|
||||
function requiredReason(value, path, errors) {
|
||||
if (typeof value !== "string" || !REASON_CODE.test(value)) errors.push(path + "_invalid");
|
||||
}
|
||||
|
||||
function requiredHash(value, path, errors) {
|
||||
if (typeof value !== "string" || !HASH.test(value)) errors.push(path + "_invalid");
|
||||
}
|
||||
|
||||
function requiredTimestamp(value, path, errors) {
|
||||
if (!isTimestamp(value)) errors.push(path + "_invalid_timestamp");
|
||||
}
|
||||
|
||||
function isTimestamp(value) {
|
||||
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function expectedTypes(state, releaseTypes) {
|
||||
return state?.kind === "release" ? [...releaseTypes] : [];
|
||||
}
|
||||
|
||||
function validateExactArray(actual, expected, path, errors) {
|
||||
if (!Array.isArray(actual)) {
|
||||
errors.push(path + "_must_be_array");
|
||||
return;
|
||||
}
|
||||
if (!sameValue(actual, [...expected])) errors.push(path + "_mismatch");
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowedKeys, path, errors) {
|
||||
if (!isPlainObject(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.has(key)) errors.push(path + "." + key + "_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sameValue(left, right) {
|
||||
return stableJson(left) === stableJson(right);
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]";
|
||||
if (isPlainObject(value)) {
|
||||
return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stableJson(value[key])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function result(errors) {
|
||||
const unique = [...new Set(errors)];
|
||||
return Object.freeze({ ok: unique.length === 0, errors: Object.freeze(unique) });
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
export const EXTERNAL_PROVIDER_CONTRACT_VERSION = "nodedc.external-provider-contract/v1";
|
||||
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1";
|
||||
|
||||
export {
|
||||
DATA_PRODUCT_PATCH_SCHEMA_VERSION,
|
||||
DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
||||
DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION,
|
||||
validateDataProductPatch,
|
||||
validateDataProductPublish,
|
||||
validateDataProductSnapshot,
|
||||
} from "./data-product.mjs";
|
||||
|
||||
export {
|
||||
ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_CAPABILITY_TYPES,
|
||||
ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION,
|
||||
computeEngineCredentialCapabilityDigest,
|
||||
computeEngineCredentialSinkPolicyHash,
|
||||
computeEngineCredentialSinkReceiptHash,
|
||||
engineCredentialSinkAuditTargets,
|
||||
validateEngineCredentialSinkAudit,
|
||||
validateEngineCredentialSinkProvision,
|
||||
validateEngineCredentialSinkReceipt,
|
||||
validateEngineCredentialSinkRollback,
|
||||
validateEngineCredentialSinkRollbackReceipt,
|
||||
} from "./engine-credential-sink.mjs";
|
||||
|
||||
export {
|
||||
ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_CREDENTIAL_SCHEMAS,
|
||||
ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES,
|
||||
ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE,
|
||||
ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY,
|
||||
ENGINE_PRIVATE_EXTENSION_NODE_TYPES,
|
||||
ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME,
|
||||
ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY,
|
||||
ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION,
|
||||
authorizeEnginePrivateExtensionOperation,
|
||||
computeEnginePrivateExtensionPlanHash,
|
||||
validateEnginePrivateExtensionApplyReceipt,
|
||||
validateEnginePrivateExtensionApplyRequest,
|
||||
validateEnginePrivateExtensionOperation,
|
||||
validateEnginePrivateExtensionPlan,
|
||||
validateEnginePrivateExtensionPlanRequest,
|
||||
validateEnginePrivateExtensionStatus,
|
||||
} from "./engine-private-extension.mjs";
|
||||
|
||||
const COLLECTION_MODES = new Set(["realtime", "manual", "weekly", "history"]);
|
||||
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
||||
const CAPABILITY_CLASSIFICATIONS = new Set(["read", "metadata", "write", "destructive", "unknown"]);
|
||||
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
|
||||
const SECRET_LIKE_REFERENCE = /(?:[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+)/i;
|
||||
const SECRET_LIKE_VALUE = /(?:ndc_edp(?:wb|rb)_[A-Za-z0-9_-]*|[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const FOUNDRY_APPLICATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const FOUNDRY_PAGE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
||||
const FOUNDRY_SLOT_ID = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
|
||||
const CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
const MAX_BATCH_SEQUENCE = 2_147_483_647;
|
||||
const MAX_FACT_ATTRIBUTES_BYTES = 64 * 1024;
|
||||
|
||||
const PROVIDER_MANIFEST_KEYS = new Set(["schemaVersion", "id", "providerId", "version", "ontology", "l2Template", "capabilities", "dataProductIds"]);
|
||||
const CONNECTION_PROFILE_KEYS = new Set(["schemaVersion", "id", "providerId", "tenantId", "credentialRef", "scope"]);
|
||||
const COLLECTION_PROFILE_KEYS = new Set(["schemaVersion", "id", "connectionId", "dataProductId", "mode", "schedule", "capabilityIds"]);
|
||||
const DATA_PRODUCT_KEYS = new Set(["schemaVersion", "id", "version", "delivery", "semanticTypes", "fields", "access"]);
|
||||
const FOUNDRY_BINDING_KEYS = new Set(["schemaVersion", "id", "dataProductId", "applicationId", "pageId", "templateId", "slotId", "semanticType"]);
|
||||
const FOUNDRY_BINDING_UPSERT_KEYS = new Set(["schemaVersion", "applicationId", "pageId", "idempotencyKey", "binding"]);
|
||||
const FOUNDRY_BINDING_UPSERT_BINDING_KEYS = new Set(["id", "dataProductId", "slotId", "semanticTypes", "fieldProjection"]);
|
||||
|
||||
/**
|
||||
* Provider-neutral, versioned description of an L2 connector template.
|
||||
*
|
||||
* This is a declarative package artifact, not a tenant connection or an
|
||||
* executable adapter. It may catalogue write capabilities, but it never
|
||||
* grants or transports them. Runtime API requests, secrets and connection
|
||||
* scope remain outside this manifest.
|
||||
*/
|
||||
export function validateProviderManifest(value) {
|
||||
const errors = baseErrors(value, "providerManifest");
|
||||
rejectUnknownKeys(value, PROVIDER_MANIFEST_KEYS, "providerManifest", errors);
|
||||
requiredIdentifier(value?.id, "id", errors);
|
||||
requiredIdentifier(value?.providerId, "providerId", errors);
|
||||
requiredString(value?.version, "version", errors);
|
||||
if (value?.version && !CONTRACT_VERSION.test(value.version)) {
|
||||
errors.push("version_must_be_semver");
|
||||
}
|
||||
|
||||
if (!isPlainObject(value?.ontology)) {
|
||||
errors.push("ontology_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.ontology, new Set(["packageId", "revision"]), "ontology", errors);
|
||||
}
|
||||
requiredIdentifier(value?.ontology?.packageId, "ontology.packageId", errors);
|
||||
requiredIdentifier(value?.ontology?.revision, "ontology.revision", errors);
|
||||
if (!isPlainObject(value?.l2Template)) {
|
||||
errors.push("l2Template_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.l2Template, new Set(["id", "version"]), "l2Template", errors);
|
||||
}
|
||||
requiredIdentifier(value?.l2Template?.id, "l2Template.id", errors);
|
||||
requiredString(value?.l2Template?.version, "l2Template.version", errors);
|
||||
if (value?.l2Template?.version && !CONTRACT_VERSION.test(value.l2Template.version)) {
|
||||
errors.push("l2Template.version_must_be_semver");
|
||||
}
|
||||
|
||||
if (!Array.isArray(value?.capabilities) || value.capabilities.length === 0) {
|
||||
errors.push("capabilities_must_be_nonempty_array");
|
||||
} else {
|
||||
value.capabilities.forEach((capability, index) => {
|
||||
if (!isPlainObject(capability)) {
|
||||
errors.push(`capabilities[${index}]_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(capability, new Set(["id", "classification"]), `capabilities[${index}]`, errors);
|
||||
requiredIdentifier(capability?.id, `capabilities[${index}].id`, errors);
|
||||
if (!CAPABILITY_CLASSIFICATIONS.has(capability?.classification)) {
|
||||
errors.push(`capabilities[${index}].classification_invalid`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(value?.dataProductIds) || value.dataProductIds.length === 0) {
|
||||
errors.push("dataProductIds_must_be_nonempty_array");
|
||||
} else {
|
||||
value.dataProductIds.forEach((dataProductId, index) => {
|
||||
requiredIdentifier(dataProductId, `dataProductIds[${index}]`, errors);
|
||||
});
|
||||
}
|
||||
|
||||
if (value?.tenantId !== undefined || value?.connectionId !== undefined || value?.credentialRef !== undefined) {
|
||||
errors.push("manifest_must_not_contain_connection_runtime_state");
|
||||
}
|
||||
if (value?.endpoint !== undefined || value?.url !== undefined || value?.host !== undefined) {
|
||||
errors.push("manifest_must_not_contain_provider_transport");
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("manifest_must_not_contain_secret_material");
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateConnectionProfile(value) {
|
||||
const errors = baseErrors(value, "connection");
|
||||
rejectUnknownKeys(value, CONNECTION_PROFILE_KEYS, "connection", errors);
|
||||
requiredIdentifier(value?.id, "id", errors);
|
||||
requiredIdentifier(value?.providerId, "providerId", errors);
|
||||
requiredIdentifier(value?.tenantId, "tenantId", errors);
|
||||
if (!isPlainObject(value?.credentialRef)) {
|
||||
errors.push("credentialRef_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.credentialRef, new Set(["owner", "reference"]), "credentialRef", errors);
|
||||
}
|
||||
requiredString(value?.credentialRef?.owner, "credentialRef.owner", errors);
|
||||
requiredString(value?.credentialRef?.reference, "credentialRef.reference", errors);
|
||||
|
||||
if (value?.credentialRef?.owner && value.credentialRef.owner !== "engine") {
|
||||
errors.push("credentialRef.owner_must_be_engine");
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material");
|
||||
if (value?.scope !== undefined) {
|
||||
if (!isPlainObject(value.scope)) {
|
||||
errors.push("scope_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.scope, new Set(["capabilityIds", "fieldPolicyId", "retentionPolicyId", "collectionProfileIds"]), "scope", errors);
|
||||
validateOptionalIdentifierArray(value.scope.capabilityIds, "scope.capabilityIds", errors);
|
||||
validateOptionalIdentifierArray(value.scope.collectionProfileIds, "scope.collectionProfileIds", errors);
|
||||
if (value.scope.fieldPolicyId !== undefined) requiredIdentifier(value.scope.fieldPolicyId, "scope.fieldPolicyId", errors);
|
||||
if (value.scope.retentionPolicyId !== undefined) requiredIdentifier(value.scope.retentionPolicyId, "scope.retentionPolicyId", errors);
|
||||
}
|
||||
}
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateCollectionProfile(value) {
|
||||
const errors = baseErrors(value, "collectionProfile");
|
||||
rejectUnknownKeys(value, COLLECTION_PROFILE_KEYS, "collectionProfile", errors);
|
||||
requiredIdentifier(value?.id, "id", errors);
|
||||
requiredIdentifier(value?.connectionId, "connectionId", errors);
|
||||
requiredIdentifier(value?.dataProductId, "dataProductId", errors);
|
||||
|
||||
if (!COLLECTION_MODES.has(value?.mode)) errors.push("mode_must_be_realtime_manual_weekly_or_history");
|
||||
if (!Array.isArray(value?.capabilityIds) || value.capabilityIds.length === 0) {
|
||||
errors.push("capabilityIds_must_be_nonempty_array");
|
||||
} else {
|
||||
value.capabilityIds.forEach((capabilityId, index) => requiredIdentifier(capabilityId, `capabilityIds[${index}]`, errors));
|
||||
}
|
||||
|
||||
const intervalMs = value?.schedule?.intervalMs;
|
||||
if (value?.schedule !== undefined) {
|
||||
if (!isPlainObject(value.schedule)) {
|
||||
errors.push("schedule_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.schedule, new Set(["intervalMs"]), "schedule", errors);
|
||||
}
|
||||
}
|
||||
if (value?.mode === "realtime") {
|
||||
if (!Number.isInteger(intervalMs) || intervalMs < 1000) errors.push("realtime_schedule_intervalMs_must_be_integer_gte_1000");
|
||||
} else if (value?.mode === "manual") {
|
||||
if (intervalMs !== undefined) errors.push("manual_profile_must_not_define_intervalMs");
|
||||
}
|
||||
|
||||
if (containsSecretLikeMaterial(value)) errors.push("collectionProfile_must_not_contain_secret_material");
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateDataProduct(value) {
|
||||
const errors = baseErrors(value, "dataProduct");
|
||||
rejectUnknownKeys(value, DATA_PRODUCT_KEYS, "dataProduct", errors);
|
||||
requiredIdentifier(value?.id, "id", errors);
|
||||
requiredString(value?.version, "version", errors);
|
||||
|
||||
if (!isPlainObject(value?.delivery)) {
|
||||
errors.push("delivery_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.delivery, new Set(["mode"]), "delivery", errors);
|
||||
}
|
||||
if (!DELIVERY_MODES.has(value?.delivery?.mode)) errors.push("delivery.mode_must_be_snapshot_snapshot+patch_or_query");
|
||||
if (!Array.isArray(value?.semanticTypes) || value.semanticTypes.length === 0) {
|
||||
errors.push("semanticTypes_must_be_nonempty_array");
|
||||
} else {
|
||||
value.semanticTypes.forEach((semanticType, index) => requiredIdentifier(semanticType, `semanticTypes[${index}]`, errors));
|
||||
}
|
||||
if (!Array.isArray(value?.fields) || value.fields.length === 0) {
|
||||
errors.push("fields_must_be_nonempty_array");
|
||||
} else {
|
||||
value.fields.forEach((field, index) => requiredIdentifier(field, `fields[${index}]`, errors));
|
||||
}
|
||||
if (!isPlainObject(value?.access)) {
|
||||
errors.push("access_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.access, new Set(["audience"]), "access", errors);
|
||||
}
|
||||
if (value?.access?.audience !== "internal") errors.push("access.audience_must_be_internal");
|
||||
if (containsSecretLikeMaterial(value)) errors.push("dataProduct_must_not_contain_secret_material");
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function validateFoundryBinding(value) {
|
||||
const errors = baseErrors(value, "foundryBinding");
|
||||
rejectUnknownKeys(value, FOUNDRY_BINDING_KEYS, "foundryBinding", errors);
|
||||
requiredIdentifier(value?.id, "id", errors);
|
||||
requiredIdentifier(value?.dataProductId, "dataProductId", errors);
|
||||
if (typeof value?.applicationId !== "string" || !FOUNDRY_APPLICATION_ID.test(value.applicationId)) {
|
||||
errors.push("applicationId_invalid");
|
||||
}
|
||||
if (typeof value?.pageId !== "string" || !FOUNDRY_PAGE_ID.test(value.pageId)) errors.push("pageId_invalid");
|
||||
if (value?.templateId !== undefined) requiredIdentifier(value.templateId, "templateId", errors);
|
||||
if (typeof value?.slotId !== "string" || !FOUNDRY_SLOT_ID.test(value.slotId)) errors.push("slotId_invalid");
|
||||
requiredIdentifier(value?.semanticType, "semanticType", errors);
|
||||
|
||||
if (containsSecretLikeMaterial(value)) errors.push("binding_must_not_contain_secret_material");
|
||||
if (value?.providerId !== undefined || value?.credentialRef !== undefined || value?.endpoint !== undefined) {
|
||||
errors.push("binding_must_reference_data_product_not_provider_transport");
|
||||
}
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay-safe control-plane command emitted by `NDC Foundry Binding`.
|
||||
*
|
||||
* This is deliberately separate from the declarative Foundry binding artifact
|
||||
* above: the command carries an idempotency key and can express a safe
|
||||
* semantic/field projection, while authorization is materialized exclusively
|
||||
* from the opaque workload grant at the receiving service.
|
||||
*/
|
||||
export function validateFoundryBindingUpsert(value) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return result(["foundryBindingUpsert_must_be_object"]);
|
||||
if (value.schemaVersion !== FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
||||
rejectUnknownKeys(value, FOUNDRY_BINDING_UPSERT_KEYS, "foundryBindingUpsert", errors);
|
||||
if (typeof value.applicationId !== "string" || !FOUNDRY_APPLICATION_ID.test(value.applicationId)) {
|
||||
errors.push("applicationId_invalid");
|
||||
}
|
||||
if (typeof value.pageId !== "string" || !FOUNDRY_PAGE_ID.test(value.pageId)) errors.push("pageId_invalid");
|
||||
requiredIdentifier(value.idempotencyKey, "idempotencyKey", errors);
|
||||
|
||||
if (!isPlainObject(value.binding)) {
|
||||
errors.push("binding_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.binding, FOUNDRY_BINDING_UPSERT_BINDING_KEYS, "binding", errors);
|
||||
requiredIdentifier(value.binding.id, "binding.id", errors);
|
||||
requiredIdentifier(value.binding.dataProductId, "binding.dataProductId", errors);
|
||||
if (typeof value.binding.slotId !== "string" || !FOUNDRY_SLOT_ID.test(value.binding.slotId)) {
|
||||
errors.push("binding.slotId_invalid");
|
||||
}
|
||||
validateRequiredUniqueIdentifierArray(value.binding.semanticTypes, "binding.semanticTypes", errors);
|
||||
validateUniqueIdentifierArray(value.binding.fieldProjection, "binding.fieldProjection", errors);
|
||||
}
|
||||
|
||||
if (containsSecretLikeMaterial(value)) errors.push("binding_must_not_contain_secret_material");
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral batch written by an L2 connector to External Data Plane.
|
||||
* The payload deliberately describes source facts rather than any provider
|
||||
* field names, customer entities or renderer representation.
|
||||
*/
|
||||
export function validateIntakeBatch(value) {
|
||||
const errors = baseErrors(value, "intakeBatch");
|
||||
rejectUnknownKeys(value, new Set(["schemaVersion", "source", "contract", "batch", "raw", "facts"]), "intakeBatch", errors);
|
||||
rejectUnknownKeys(value?.source, new Set(["providerId", "tenantId", "connectionId"]), "source", errors);
|
||||
rejectUnknownKeys(value?.contract, new Set(["dataProductId", "ontologyRevision", "version"]), "contract", errors);
|
||||
rejectUnknownKeys(value?.batch, new Set(["runId", "sequence", "idempotencyKey", "receivedAt"]), "batch", errors);
|
||||
requiredIdentifier(value?.source?.providerId, "source.providerId", errors);
|
||||
requiredIdentifier(value?.source?.tenantId, "source.tenantId", errors);
|
||||
requiredIdentifier(value?.source?.connectionId, "source.connectionId", errors);
|
||||
requiredIdentifier(value?.contract?.dataProductId, "contract.dataProductId", errors);
|
||||
requiredIdentifier(value?.contract?.ontologyRevision, "contract.ontologyRevision", errors);
|
||||
requiredString(value?.contract?.version, "contract.version", errors);
|
||||
if (value?.contract?.version && !CONTRACT_VERSION.test(value.contract.version)) {
|
||||
errors.push("contract.version_must_be_semver");
|
||||
}
|
||||
requiredIdentifier(value?.batch?.runId, "batch.runId", errors);
|
||||
requiredIdentifier(value?.batch?.idempotencyKey, "batch.idempotencyKey", errors);
|
||||
if (!Number.isInteger(value?.batch?.sequence) || value.batch.sequence < 0 || value.batch.sequence > MAX_BATCH_SEQUENCE) {
|
||||
errors.push("batch.sequence_must_be_integer_0_to_2147483647");
|
||||
}
|
||||
requiredIsoTimestamp(value?.batch?.receivedAt, "batch.receivedAt", errors);
|
||||
|
||||
if (!Array.isArray(value?.facts) || value.facts.length === 0) {
|
||||
errors.push("facts_must_be_nonempty_array");
|
||||
} else {
|
||||
value.facts.forEach((fact, index) => validateFact(fact, `facts[${index}]`, errors));
|
||||
}
|
||||
|
||||
if (value?.raw !== undefined) validateRawEnvelope(value.raw, errors);
|
||||
if (containsSecretLikeMaterial(value)) errors.push("intake_must_not_contain_secret_material");
|
||||
|
||||
return result(errors);
|
||||
}
|
||||
|
||||
export function assertValid(validator, value) {
|
||||
const validation = validator(value);
|
||||
if (!validation.ok) throw new Error(`external_provider_contract_invalid:${validation.errors.join(",")}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function baseErrors(value, label) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(value)) return [`${label}_must_be_object`];
|
||||
if (value.schemaVersion !== EXTERNAL_PROVIDER_CONTRACT_VERSION) {
|
||||
errors.push("schemaVersion_mismatch");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function result(errors) {
|
||||
const uniqueErrors = [...new Set(errors)];
|
||||
return Object.freeze({ ok: uniqueErrors.length === 0, errors: Object.freeze(uniqueErrors) });
|
||||
}
|
||||
|
||||
function requiredString(value, path, errors) {
|
||||
if (typeof value !== "string" || !value.trim()) errors.push(`${path}_required`);
|
||||
}
|
||||
|
||||
function requiredIdentifier(value, path, errors) {
|
||||
if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function validateOptionalIdentifierArray(value, path, errors) {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${path}_must_be_array`);
|
||||
return;
|
||||
}
|
||||
value.forEach((item, index) => requiredIdentifier(item, `${path}[${index}]`, errors));
|
||||
}
|
||||
|
||||
function validateRequiredUniqueIdentifierArray(value, path, errors) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${path}_must_be_nonempty_array`);
|
||||
return;
|
||||
}
|
||||
validateUniqueIdentifierArray(value, path, errors);
|
||||
}
|
||||
|
||||
function validateUniqueIdentifierArray(value, path, errors) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${path}_must_be_array`);
|
||||
return;
|
||||
}
|
||||
value.forEach((item, index) => requiredIdentifier(item, `${path}[${index}]`, errors));
|
||||
if (new Set(value).size !== value.length) errors.push(`${path}_must_not_contain_duplicates`);
|
||||
}
|
||||
|
||||
function requiredIsoTimestamp(value, path, errors) {
|
||||
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) errors.push(`${path}_invalid_timestamp`);
|
||||
}
|
||||
|
||||
function validateFact(value, path, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_must_be_object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["sourceId", "semanticType", "observedAt", "attributes", "geometry"]), path, errors);
|
||||
requiredIdentifier(value.sourceId, `${path}.sourceId`, errors);
|
||||
requiredIdentifier(value.semanticType, `${path}.semanticType`, errors);
|
||||
requiredIsoTimestamp(value.observedAt, `${path}.observedAt`, errors);
|
||||
if (value.attributes !== undefined) {
|
||||
if (!isPlainObject(value.attributes)) {
|
||||
errors.push(`${path}.attributes_must_be_object`);
|
||||
} else if (serializedByteLength(value.attributes) > MAX_FACT_ATTRIBUTES_BYTES) {
|
||||
errors.push(`${path}.attributes_size_exceeded`);
|
||||
}
|
||||
}
|
||||
if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
}
|
||||
|
||||
function validatePointGeometry(value, path, errors) {
|
||||
if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
|
||||
errors.push(`${path}_must_be_geojson_point`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors);
|
||||
if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) {
|
||||
errors.push(`${path}_coordinates_must_be_finite_numbers`);
|
||||
return;
|
||||
}
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`);
|
||||
if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`);
|
||||
}
|
||||
|
||||
function validateRawEnvelope(value, errors) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push("raw_must_be_object");
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["contentType", "payload", "hash", "ref", "retentionDays"]), "raw", errors);
|
||||
requiredString(value.contentType, "raw.contentType", errors);
|
||||
if (value.payload === undefined && value.ref === undefined) errors.push("raw_requires_payload_or_ref");
|
||||
if (value.payload !== undefined) errors.push("raw.inline_payload_not_supported");
|
||||
if (value.payload === undefined) requiredString(value.hash, "raw.hash", errors);
|
||||
if (value.hash !== undefined) requiredString(value.hash, "raw.hash", errors);
|
||||
if (value.ref !== undefined) {
|
||||
requiredString(value.ref, "raw.ref", errors);
|
||||
if (typeof value.ref === "string" && SECRET_LIKE_REFERENCE.test(value.ref)) {
|
||||
errors.push("raw.ref_must_not_contain_secret_material");
|
||||
}
|
||||
}
|
||||
if (value.retentionDays !== undefined && (!Number.isInteger(value.retentionDays) || value.retentionDays < 1)) {
|
||||
errors.push("raw.retentionDays_must_be_positive_integer");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowedKeys, path, errors) {
|
||||
if (!isPlainObject(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.has(key)) errors.push(`${path}.${key}_not_allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function serializedByteLength(value) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value));
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
function containsSecretLikeMaterial(value) {
|
||||
if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
|
||||
if (Array.isArray(value)) return value.some(containsSecretLikeMaterial);
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child));
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
||||
assertValid,
|
||||
validateCollectionProfile,
|
||||
validateConnectionProfile,
|
||||
validateDataProduct,
|
||||
validateFoundryBinding,
|
||||
validateFoundryBindingUpsert,
|
||||
validateIntakeBatch,
|
||||
validateProviderManifest,
|
||||
} from "../src/index.mjs";
|
||||
import { geliosPositionsCurrentExample } from "../examples/gelios-positions-current.v1.mjs";
|
||||
|
||||
assert.equal(validateProviderManifest(geliosPositionsCurrentExample.providerManifest).ok, true);
|
||||
assert.equal(validateConnectionProfile(geliosPositionsCurrentExample.connection).ok, true);
|
||||
assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true);
|
||||
assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true);
|
||||
assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);
|
||||
|
||||
const foundryBindingUpsert = {
|
||||
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
||||
applicationId: "11111111-1111-4111-8111-111111111111",
|
||||
pageId: "map",
|
||||
idempotencyKey: "foundry-binding-0123456789abcdef0123456789abcdef",
|
||||
binding: {
|
||||
id: "fleet-live-points",
|
||||
dataProductId: "fleet.positions.current.v1",
|
||||
slotId: "points",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fieldProjection: ["name", "speed", "course"],
|
||||
},
|
||||
};
|
||||
assert.equal(validateFoundryBindingUpsert(foundryBindingUpsert).ok, true);
|
||||
assert.equal(validateFoundryBindingUpsert({
|
||||
...foundryBindingUpsert,
|
||||
binding: { ...foundryBindingUpsert.binding, semanticTypes: ["map.moving_object", "map.moving_object"] },
|
||||
}).errors.includes("binding.semanticTypes_must_not_contain_duplicates"), true);
|
||||
assert.equal(validateFoundryBindingUpsert({
|
||||
...foundryBindingUpsert,
|
||||
binding: { ...foundryBindingUpsert.binding, accessToken: "forbidden" },
|
||||
}).errors.includes("binding.accessToken_not_allowed"), true);
|
||||
assert.equal(validateFoundryBindingUpsert({
|
||||
...foundryBindingUpsert,
|
||||
binding: { ...foundryBindingUpsert.binding, fieldProjection: ["ndc_edprb_forbidden-reader-token"] },
|
||||
}).errors.includes("binding_must_not_contain_secret_material"), true);
|
||||
|
||||
const attributesWithSerializedSize = (size) => ({
|
||||
blob: "x".repeat(size - Buffer.byteLength(JSON.stringify({ blob: "" }))),
|
||||
});
|
||||
|
||||
const neutralIntakeBatch = {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
source: {
|
||||
providerId: "example-provider",
|
||||
tenantId: "sample-tenant",
|
||||
connectionId: "sample-connection",
|
||||
},
|
||||
contract: {
|
||||
dataProductId: "fleet.positions.current.v1",
|
||||
ontologyRevision: "ontology.example-fleet.v1",
|
||||
version: "1.0.0",
|
||||
},
|
||||
batch: {
|
||||
runId: "run-20260714-001",
|
||||
sequence: 0,
|
||||
idempotencyKey: "run-20260714-001.batch-0",
|
||||
receivedAt: "2026-07-14T18:00:00.000Z",
|
||||
},
|
||||
raw: {
|
||||
contentType: "application/json",
|
||||
hash: "sha256:example",
|
||||
ref: "restricted://raw/run-20260714-001",
|
||||
retentionDays: 14,
|
||||
},
|
||||
facts: [{
|
||||
sourceId: "source-object-42",
|
||||
semanticType: "map.moving_object",
|
||||
observedAt: "2026-07-14T17:59:58.000Z",
|
||||
geometry: { type: "Point", coordinates: [37.6173, 55.7558] },
|
||||
attributes: { status: "active" },
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(validateIntakeBatch(neutralIntakeBatch).ok, true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
raw: { contentType: "application/json", payload: { safe: "fixture" } },
|
||||
}).errors.includes("raw.inline_payload_not_supported"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
raw: { contentType: "application/json", payload: "unsafe-unstructured-inline-raw" },
|
||||
}).errors.includes("raw.inline_payload_not_supported"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
raw: { contentType: "application/json", payload: { providerAccessToken: "must-never-be-stored" } },
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: { authorization: "Bearer must-never-be-stored" } }],
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: { metadata: "ndc_edpwb_abcdefghijklmnopqrstuvwxyz0123456789ABCDE" } }],
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: { metadata: "ndc_edprb_abcdefghijklmnopqrstuvwxyz0123456789ABCDE" } }],
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
raw: { contentType: "application/json", hash: "prefix ndc_edpwb_abcdefghijklmnopqrstuvwxyz0123456789ABCDE suffix", ref: "restricted://raw/fixture" },
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: { metadata: "Bearer opaque-secret-material" } }],
|
||||
}).errors.includes("intake_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
raw: { contentType: "application/json", hash: "sha256:fixture", ref: "restricted://raw?access_token=must-never-be-stored" },
|
||||
}).errors.includes("raw.ref_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], sourceId: "" }],
|
||||
}).errors.includes("facts[0].sourceId_invalid"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
batch: { ...neutralIntakeBatch.batch, sequence: 2_147_483_647 },
|
||||
}).ok, true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
batch: { ...neutralIntakeBatch.batch, sequence: 2_147_483_648 },
|
||||
}).errors.includes("batch.sequence_must_be_integer_0_to_2147483647"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: attributesWithSerializedSize(64 * 1024) }],
|
||||
}).ok, true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], attributes: attributesWithSerializedSize((64 * 1024) + 1) }],
|
||||
}).errors.includes("facts[0].attributes_size_exceeded"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], geometry: { type: "Point", coordinates: [180.0001, 55.7558] } }],
|
||||
}).errors.includes("facts[0].geometry.longitude_out_of_range"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
facts: [{ ...neutralIntakeBatch.facts[0], geometry: { type: "Point", coordinates: [37.6173, -90.0001] } }],
|
||||
}).errors.includes("facts[0].geometry.latitude_out_of_range"), true);
|
||||
assert.equal(validateIntakeBatch({
|
||||
...neutralIntakeBatch,
|
||||
debug: true,
|
||||
}).errors.includes("intakeBatch.debug_not_allowed"), true);
|
||||
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
apiToken: "must-never-be-here",
|
||||
}).ok, false);
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
credentialRef: {
|
||||
...geliosPositionsCurrentExample.connection.credentialRef,
|
||||
reference: "ndc_edpwb_not-allowed-even-in-a-non-secret-field",
|
||||
},
|
||||
}).errors.includes("profile_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
credentialRef: {
|
||||
...geliosPositionsCurrentExample.connection.credentialRef,
|
||||
reference: "opaque-reference?access_token=must-not-cross-the-boundary",
|
||||
},
|
||||
}).errors.includes("profile_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, namespace: "unexpected" },
|
||||
}).errors.includes("credentialRef.namespace_not_allowed"), true);
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
scope: { ...geliosPositionsCurrentExample.connection.scope, providerSelector: "unexpected" },
|
||||
}).errors.includes("scope.providerSelector_not_allowed"), true);
|
||||
assert.equal(validateCollectionProfile({
|
||||
...geliosPositionsCurrentExample.collectionProfile,
|
||||
mode: "manual",
|
||||
}).errors.includes("manual_profile_must_not_define_intervalMs"), true);
|
||||
assert.equal(validateCollectionProfile({
|
||||
...geliosPositionsCurrentExample.collectionProfile,
|
||||
schedule: { intervalMs: 3000, jitterMs: 100 },
|
||||
}).errors.includes("schedule.jitterMs_not_allowed"), true);
|
||||
assert.equal(validateCollectionProfile({
|
||||
...geliosPositionsCurrentExample.collectionProfile,
|
||||
capabilityIds: ["ndc_edprb_forbidden-reader-token"],
|
||||
}).errors.includes("collectionProfile_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
providerId: "gelios",
|
||||
}).errors.includes("binding_must_reference_data_product_not_provider_transport"), true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
applicationId: undefined,
|
||||
}).errors.includes("applicationId_invalid"), true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
pageId: undefined,
|
||||
}).errors.includes("pageId_invalid"), true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
templateId: undefined,
|
||||
}).ok, true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
rendererOptions: {},
|
||||
}).errors.includes("foundryBinding.rendererOptions_not_allowed"), true);
|
||||
assert.equal(validateFoundryBinding({
|
||||
...geliosPositionsCurrentExample.foundryBinding,
|
||||
semanticType: "ndc_edprb_forbidden-reader-token",
|
||||
}).errors.includes("binding_must_not_contain_secret_material"), true);
|
||||
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
tenantId: "must-not-live-in-provider-manifest",
|
||||
}).errors.includes("manifest_must_not_contain_connection_runtime_state"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
apiToken: "must-never-be-here",
|
||||
}).errors.includes("manifest_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
endpoint: "https://must-live-in-l2-template.invalid",
|
||||
}).errors.includes("manifest_must_not_contain_provider_transport"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
capabilities: [{ id: "gelios.units.current.read", classification: "not-a-class" }],
|
||||
}).errors.includes("capabilities[0].classification_invalid"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
metadata: {},
|
||||
}).errors.includes("providerManifest.metadata_not_allowed"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
ontology: { ...geliosPositionsCurrentExample.providerManifest.ontology, transport: "unexpected" },
|
||||
}).errors.includes("ontology.transport_not_allowed"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
capabilities: [{ id: "gelios.units.current.read", classification: "read", endpoint: "/units" }],
|
||||
}).errors.includes("capabilities[0].endpoint_not_allowed"), true);
|
||||
assert.equal(validateProviderManifest({
|
||||
...geliosPositionsCurrentExample.providerManifest,
|
||||
dataProductIds: ["ndc_edpwb_forbidden-writer-token"],
|
||||
}).errors.includes("manifest_must_not_contain_secret_material"), true);
|
||||
|
||||
assert.equal(validateDataProduct({
|
||||
...geliosPositionsCurrentExample.dataProduct,
|
||||
delivery: { mode: "snapshot+patch", transport: "sse" },
|
||||
}).errors.includes("delivery.transport_not_allowed"), true);
|
||||
|
||||
assert.throws(() => assertValid(validateDataProduct, {
|
||||
schemaVersion: EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
id: "fleet.positions.current.v1",
|
||||
version: "1.0.0",
|
||||
delivery: { mode: "snapshot" },
|
||||
semanticTypes: [],
|
||||
fields: [],
|
||||
access: { audience: "public" },
|
||||
}));
|
||||
|
||||
console.log("external-provider-contract: ok");
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DATA_PRODUCT_PATCH_SCHEMA_VERSION,
|
||||
DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
||||
DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION,
|
||||
validateDataProductPatch,
|
||||
validateDataProductPublish,
|
||||
validateDataProductSnapshot,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const fact = {
|
||||
sourceId: "unit-42",
|
||||
semanticType: "map.moving_object",
|
||||
observedAt: "2026-07-15T10:00:00.000Z",
|
||||
attributes: { status: "online" },
|
||||
geometry: { type: "Point", coordinates: [37.6173, 55.7558] },
|
||||
};
|
||||
|
||||
const attributesWithSerializedSize = (size) => ({
|
||||
blob: "x".repeat(size - Buffer.byteLength(JSON.stringify({ blob: "" }))),
|
||||
});
|
||||
|
||||
const publish = {
|
||||
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
||||
batch: { runId: "execution-42", sequence: 0, idempotencyKey: "execution-42.node-01.chunk-0" },
|
||||
facts: [fact],
|
||||
};
|
||||
assert.equal(validateDataProductPublish(publish).ok, true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
source: { tenantId: "forged" },
|
||||
}).errors.includes("publish.source_not_allowed"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, attributes: { accessToken: "must-never-cross-the-boundary" } }],
|
||||
}).errors.includes("publish_must_not_contain_secret_material"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [fact, { ...fact, observedAt: "2026-07-15T10:00:01.000Z" }],
|
||||
}).errors.includes("facts_duplicate_entity_key"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
batch: { ...publish.batch, sequence: 2_147_483_647 },
|
||||
}).ok, true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
batch: { ...publish.batch, sequence: 2_147_483_648 },
|
||||
}).errors.includes("batch.sequence_must_be_integer_0_to_2147483647"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, attributes: attributesWithSerializedSize(64 * 1024) }],
|
||||
}).ok, true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, attributes: attributesWithSerializedSize((64 * 1024) + 1) }],
|
||||
}).errors.includes("facts[0].attributes_size_exceeded"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, attributes: attributesWithSerializedSize((64 * 1024) + 1) }],
|
||||
}, { maxAttributesBytes: 1024 * 1024 }).errors.includes("facts[0].attributes_size_exceeded"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, geometry: { type: "Point", coordinates: [-180.0001, 55.7558] } }],
|
||||
}).errors.includes("facts[0].geometry.longitude_out_of_range"), true);
|
||||
assert.equal(validateDataProductPublish({
|
||||
...publish,
|
||||
facts: [{ ...fact, geometry: { type: "Point", coordinates: [37.6173, 90.0001] } }],
|
||||
}).errors.includes("facts[0].geometry.latitude_out_of_range"), true);
|
||||
|
||||
const canonicalFact = { ...fact, receivedAt: "2026-07-15T10:00:01.000Z" };
|
||||
const snapshot = {
|
||||
schemaVersion: DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION,
|
||||
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
|
||||
generatedAt: "2026-07-15T10:00:01.000Z",
|
||||
cursor: "12",
|
||||
facts: [canonicalFact],
|
||||
};
|
||||
assert.equal(validateDataProductSnapshot(snapshot).ok, true);
|
||||
assert.equal(validateDataProductSnapshot({ ...snapshot, transport: "sse" }).errors.includes("snapshot.transport_not_allowed"), true);
|
||||
assert.equal(validateDataProductSnapshot({
|
||||
...snapshot,
|
||||
facts: [{ ...canonicalFact, attributes: attributesWithSerializedSize((64 * 1024) + 1) }],
|
||||
}).errors.includes("facts[0].attributes_size_exceeded"), true);
|
||||
assert.equal(validateDataProductSnapshot({
|
||||
...snapshot,
|
||||
facts: [{ ...canonicalFact, attributes: { status: "ndc_edprb_forbidden-reader-token" } }],
|
||||
}).errors.includes("snapshot_must_not_contain_secret_material"), true);
|
||||
|
||||
const patch = {
|
||||
schemaVersion: DATA_PRODUCT_PATCH_SCHEMA_VERSION,
|
||||
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
|
||||
cursor: "13",
|
||||
previousCursor: "12",
|
||||
emittedAt: "2026-07-15T10:00:02.000Z",
|
||||
operations: [{ op: "upsert", fact: canonicalFact }],
|
||||
};
|
||||
assert.equal(validateDataProductPatch(patch).ok, true);
|
||||
assert.equal(validateDataProductPatch({
|
||||
...patch,
|
||||
operations: [{ op: "delete", fact: canonicalFact }],
|
||||
}).errors.includes("operations[0].op_must_be_upsert"), true);
|
||||
assert.equal(validateDataProductPatch({
|
||||
...patch,
|
||||
operations: [{ op: "upsert", fact: { ...canonicalFact, attributes: attributesWithSerializedSize((64 * 1024) + 1) } }],
|
||||
}).errors.includes("operations[0].fact.attributes_size_exceeded"), true);
|
||||
assert.equal(validateDataProductPatch({
|
||||
...patch,
|
||||
operations: [{ op: "upsert", fact: { ...canonicalFact, attributes: { status: "ndc_edpwb_forbidden-writer-token" } } }],
|
||||
}).errors.includes("patch_must_not_contain_secret_material"), true);
|
||||
|
||||
console.log("data-product-contract: ok");
|
||||
@@ -0,0 +1,242 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
|
||||
import {
|
||||
ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION,
|
||||
computeEngineCredentialCapabilityDigest,
|
||||
computeEngineCredentialSinkPolicyHash,
|
||||
computeEngineCredentialSinkReceiptHash,
|
||||
engineCredentialSinkAuditTargets,
|
||||
validateEngineCredentialSinkAudit,
|
||||
validateEngineCredentialSinkProvision,
|
||||
validateEngineCredentialSinkReceipt,
|
||||
validateEngineCredentialSinkRollback,
|
||||
validateEngineCredentialSinkRollbackReceipt,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const hash = (character) => `sha256:${character.repeat(64)}`;
|
||||
const issuer = { serviceId: "platform.external-data-plane", keyId: "edp-issuer-20260715-001" };
|
||||
const { publicKey: issuerPublicKey, privateKey: issuerPrivateKey } = generateKeyPairSync("ed25519");
|
||||
const issuerPublicKeys = { [`${issuer.serviceId}:${issuer.keyId}`]: issuerPublicKey };
|
||||
const target = (nodeId, nodeType, credentialType) => ({
|
||||
workflowId: "WCb62yGL8v",
|
||||
workflowRevision: "revision-20260715-001",
|
||||
nodeId,
|
||||
nodeType,
|
||||
credentialType,
|
||||
});
|
||||
|
||||
const request = {
|
||||
schemaVersion: ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION,
|
||||
transaction: {
|
||||
id: "credential-transaction-20260715-001",
|
||||
idempotencyKey: "credential-transaction-20260715-001",
|
||||
requestedAt: "2026-07-15T18:00:00.000Z",
|
||||
requestExpiresAt: "2026-07-15T18:10:00.000Z",
|
||||
policyHash: hash("0"),
|
||||
failureMode: "rollback-all",
|
||||
issuer,
|
||||
attestation: { algorithm: "Ed25519", signature: "A".repeat(86) },
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
bindingId: "positions-writer",
|
||||
capabilityType: "external-data-plane.writer",
|
||||
grantId: "writer-grant-001",
|
||||
target: target(
|
||||
"publish-node-001",
|
||||
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||
"ndcDataProductWriterApi",
|
||||
),
|
||||
expiresAt: "2026-08-15T18:00:00.000Z",
|
||||
policyHash: hash("1"),
|
||||
material: { format: "opaque-bearer", value: `ndc_edpwb_${"A".repeat(43)}` },
|
||||
},
|
||||
{
|
||||
bindingId: "positions-reader",
|
||||
capabilityType: "external-data-plane.reader",
|
||||
grantId: "reader-grant-001",
|
||||
target: target(
|
||||
"read-node-001",
|
||||
"n8n-nodes-ndc.ndcDataProductRead",
|
||||
"ndcDataProductReaderApi",
|
||||
),
|
||||
expiresAt: "2026-08-15T18:00:00.000Z",
|
||||
policyHash: hash("2"),
|
||||
material: { format: "opaque-bearer", value: `ndc_edprb_${"B".repeat(43)}` },
|
||||
},
|
||||
{
|
||||
bindingId: "positions-foundry",
|
||||
capabilityType: "foundry.binding",
|
||||
grantId: "foundry-grant-001",
|
||||
target: target(
|
||||
"foundry-node-001",
|
||||
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||
"ndcFoundryBindingApi",
|
||||
),
|
||||
expiresAt: "2026-08-15T18:00:00.000Z",
|
||||
policyHash: hash("3"),
|
||||
material: { format: "opaque-bearer", value: `ndc_fndbg_${"C".repeat(43)}` },
|
||||
},
|
||||
],
|
||||
};
|
||||
for (const binding of request.bindings) {
|
||||
binding.capabilityDigest = computeEngineCredentialCapabilityDigest(binding.material.value);
|
||||
}
|
||||
attest(request);
|
||||
const provisionValidationNow = "2026-07-15T18:00:01.000Z";
|
||||
|
||||
assert.equal(validateEngineCredentialSinkProvision(request, { now: provisionValidationNow, issuerPublicKeys }).ok, true);
|
||||
assert.equal(validateEngineCredentialSinkProvision(request, {
|
||||
now: provisionValidationNow,
|
||||
}).errors.includes("transaction.issuer_public_key_required"), true);
|
||||
assert.equal(validateEngineCredentialSinkProvision({
|
||||
...request,
|
||||
transaction: { ...request.transaction, policyHash: hash("f") },
|
||||
}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("transaction.policyHash_mismatch"), true);
|
||||
assert.equal(validateEngineCredentialSinkProvision({
|
||||
...request,
|
||||
bindings: [{
|
||||
...request.bindings[0],
|
||||
target: { ...request.bindings[0].target, nodeType: "n8n-nodes-ndc.ndcDataProductRead" },
|
||||
}],
|
||||
}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("bindings[0].target.nodeType_capability_mismatch"), true);
|
||||
assert.equal(validateEngineCredentialSinkProvision({
|
||||
...request,
|
||||
bindings: [{
|
||||
...request.bindings[0],
|
||||
material: { format: "opaque-bearer", value: request.bindings[1].material.value },
|
||||
}],
|
||||
}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("bindings[0].material.value_invalid_for_capability"), true);
|
||||
|
||||
const swappedCapabilityRequest = structuredClone(request);
|
||||
swappedCapabilityRequest.bindings[0].material.value = `ndc_edpwb_${"D".repeat(43)}`;
|
||||
assert.equal(validateEngineCredentialSinkProvision(swappedCapabilityRequest, {
|
||||
now: provisionValidationNow,
|
||||
issuerPublicKeys,
|
||||
}).errors.includes("bindings[0].capabilityDigest_material_mismatch"), true);
|
||||
|
||||
const resignedByAttackerRequest = structuredClone(swappedCapabilityRequest);
|
||||
resignedByAttackerRequest.bindings[0].capabilityDigest = computeEngineCredentialCapabilityDigest(
|
||||
resignedByAttackerRequest.bindings[0].material.value,
|
||||
);
|
||||
resignedByAttackerRequest.transaction.policyHash = computeEngineCredentialSinkPolicyHash(resignedByAttackerRequest);
|
||||
assert.equal(validateEngineCredentialSinkProvision(resignedByAttackerRequest, {
|
||||
now: provisionValidationNow,
|
||||
issuerPublicKeys,
|
||||
}).errors.includes("transaction.attestation_invalid"), true);
|
||||
|
||||
const staleRequest = structuredClone(request);
|
||||
staleRequest.transaction.requestedAt = "2020-01-01T00:00:00.000Z";
|
||||
staleRequest.transaction.requestExpiresAt = "2020-01-01T00:10:00.000Z";
|
||||
attest(staleRequest);
|
||||
assert.equal(validateEngineCredentialSinkProvision(staleRequest, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("request_expired"), true);
|
||||
|
||||
const futureRequest = structuredClone(request);
|
||||
futureRequest.transaction.requestedAt = "2026-07-15T18:02:00.000Z";
|
||||
futureRequest.transaction.requestExpiresAt = "2026-07-15T18:12:00.000Z";
|
||||
attest(futureRequest);
|
||||
assert.equal(validateEngineCredentialSinkProvision(futureRequest, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("requestedAt_exceeds_clock_skew"), true);
|
||||
|
||||
const credentials = request.bindings.map((binding, index) => ({
|
||||
bindingId: binding.bindingId,
|
||||
capabilityType: binding.capabilityType,
|
||||
grantId: binding.grantId,
|
||||
target: binding.target,
|
||||
credentialRef: `engcred_${index + 1}_opaque_reference`,
|
||||
expiresAt: binding.expiresAt,
|
||||
policyHash: binding.policyHash,
|
||||
capabilityDigest: binding.capabilityDigest,
|
||||
disposition: "created",
|
||||
}));
|
||||
const receipt = {
|
||||
schemaVersion: ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION,
|
||||
transactionId: request.transaction.id,
|
||||
idempotencyKey: request.transaction.idempotencyKey,
|
||||
outcome: "committed",
|
||||
policyHash: request.transaction.policyHash,
|
||||
processedAt: "2026-07-15T18:00:02.000Z",
|
||||
credentials,
|
||||
rollback: { status: "not-required" },
|
||||
};
|
||||
assert.equal(validateEngineCredentialSinkReceipt(receipt, { request, issuerPublicKeys }).ok, true);
|
||||
assert.equal(validateEngineCredentialSinkReceipt({
|
||||
...receipt,
|
||||
credentials: [{ ...credentials[0], target: { ...credentials[0].target, nodeId: "wrong-node" } }, ...credentials.slice(1)],
|
||||
}, { request, issuerPublicKeys }).errors.includes("credentials_request_target_mismatch"), true);
|
||||
assert.equal(validateEngineCredentialSinkReceipt({
|
||||
...receipt,
|
||||
capability: request.bindings[0].material.value,
|
||||
}).errors.includes("credentialSinkReceipt.capability_not_allowed"), true);
|
||||
assert.equal(validateEngineCredentialSinkReceipt({
|
||||
...receipt,
|
||||
outcome: "rolled-back",
|
||||
credentials: credentials.slice(0, 1),
|
||||
rollback: { status: "complete", completedAt: "2026-07-15T18:00:02.000Z" },
|
||||
errorCode: "engine_binding_failed",
|
||||
}).errors.includes("noncommitted_credentials_must_be_empty"), true);
|
||||
|
||||
const committedReceiptHash = computeEngineCredentialSinkReceiptHash(receipt);
|
||||
const rollbackRequest = {
|
||||
schemaVersion: ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION,
|
||||
rollback: {
|
||||
id: "credential-rollback-20260715-001",
|
||||
idempotencyKey: "credential-rollback-20260715-001",
|
||||
transactionId: request.transaction.id,
|
||||
requestedAt: "2026-07-15T18:05:00.000Z",
|
||||
requestExpiresAt: "2026-07-15T18:10:00.000Z",
|
||||
policyHash: request.transaction.policyHash,
|
||||
committedReceiptHash,
|
||||
reasonCode: "operator_requested",
|
||||
},
|
||||
};
|
||||
assert.equal(validateEngineCredentialSinkRollback(rollbackRequest, { now: "2026-07-15T18:05:01.000Z" }).ok, true);
|
||||
const staleRollbackRequest = structuredClone(rollbackRequest);
|
||||
staleRollbackRequest.rollback.requestedAt = "2020-01-01T00:00:00.000Z";
|
||||
staleRollbackRequest.rollback.requestExpiresAt = "2020-01-01T00:10:00.000Z";
|
||||
assert.equal(validateEngineCredentialSinkRollback(staleRollbackRequest, { now: "2026-07-15T18:05:01.000Z" }).errors.includes("request_expired"), true);
|
||||
const rollbackReceipt = {
|
||||
schemaVersion: ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION,
|
||||
rollbackId: rollbackRequest.rollback.id,
|
||||
transactionId: rollbackRequest.rollback.transactionId,
|
||||
outcome: "rolled-back",
|
||||
policyHash: rollbackRequest.rollback.policyHash,
|
||||
committedReceiptHash,
|
||||
processedAt: "2026-07-15T18:05:01.000Z",
|
||||
};
|
||||
assert.equal(validateEngineCredentialSinkRollbackReceipt(rollbackReceipt, { request: rollbackRequest }).ok, true);
|
||||
|
||||
const audit = {
|
||||
schemaVersion: ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION,
|
||||
eventId: "credential-audit-20260715-001",
|
||||
transactionId: request.transaction.id,
|
||||
operationId: request.transaction.id,
|
||||
operation: "provision",
|
||||
outcome: "committed",
|
||||
occurredAt: receipt.processedAt,
|
||||
policyHash: request.transaction.policyHash,
|
||||
principal: { serviceId: "platform.credential-provisioner", fingerprint: hash("a") },
|
||||
targets: engineCredentialSinkAuditTargets(request, receipt),
|
||||
};
|
||||
assert.equal(validateEngineCredentialSinkAudit(audit).ok, true);
|
||||
assert.equal(JSON.stringify(audit).includes("ndc_edpwb_"), false);
|
||||
assert.equal(JSON.stringify(audit).includes("engcred_1_opaque_reference"), false);
|
||||
assert.equal(validateEngineCredentialSinkAudit({
|
||||
...audit,
|
||||
authorization: `Bearer ${request.bindings[0].material.value}`,
|
||||
}).errors.includes("audit_must_not_contain_secret_material"), true);
|
||||
|
||||
function attest(value) {
|
||||
value.transaction.policyHash = computeEngineCredentialSinkPolicyHash(value);
|
||||
value.transaction.attestation.signature = sign(
|
||||
null,
|
||||
Buffer.from(value.transaction.policyHash, "utf8"),
|
||||
issuerPrivateKey,
|
||||
).toString("base64url");
|
||||
}
|
||||
|
||||
console.log("external-provider credential sink contract: ok");
|
||||
@@ -0,0 +1,370 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES,
|
||||
ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE,
|
||||
ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY,
|
||||
ENGINE_PRIVATE_EXTENSION_NODE_TYPES,
|
||||
ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME,
|
||||
ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION,
|
||||
ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY,
|
||||
ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION,
|
||||
authorizeEnginePrivateExtensionOperation,
|
||||
computeEnginePrivateExtensionPlanHash,
|
||||
validateEnginePrivateExtensionApplyReceipt,
|
||||
validateEnginePrivateExtensionApplyRequest,
|
||||
validateEnginePrivateExtensionOperation,
|
||||
validateEnginePrivateExtensionPlan,
|
||||
validateEnginePrivateExtensionPlanRequest,
|
||||
validateEnginePrivateExtensionStatus,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const digest = "03413c6f3c706a1f6a9597a1cf1730504e9719228d0c77fdfb93bbdcc57a4cf3";
|
||||
const release = Object.freeze({
|
||||
kind: "release",
|
||||
packageName: ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME,
|
||||
releaseId: "0.1.0-03413c6f3c706a1f",
|
||||
packageSha256: digest,
|
||||
});
|
||||
const inactive = Object.freeze({
|
||||
kind: "inactive-baseline",
|
||||
packageName: ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME,
|
||||
baselineId: ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE,
|
||||
});
|
||||
const manage = [ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY];
|
||||
|
||||
const activateRequest = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION,
|
||||
requestId: "extension-request-20260715-001",
|
||||
idempotencyKey: "extension-request-20260715-001",
|
||||
action: "activate",
|
||||
requestedAt: "2026-07-15T18:00:00.000Z",
|
||||
requestExpiresAt: "2026-07-15T18:10:00.000Z",
|
||||
expectedCurrentGeneration: 0,
|
||||
target: release,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
validateEnginePrivateExtensionPlanRequest(activateRequest, {
|
||||
now: "2026-07-15T18:00:01.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}),
|
||||
{ ok: true, errors: [] },
|
||||
);
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlanRequest(activateRequest, {
|
||||
now: "2026-07-15T18:00:01.000Z",
|
||||
grantedCapabilities: ["engine.l2.deploy"],
|
||||
}).errors.includes("engine_private_extension_capability_required"),
|
||||
true,
|
||||
);
|
||||
assert.equal(authorizeEnginePrivateExtensionOperation("status", [ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY]).ok, true);
|
||||
assert.equal(authorizeEnginePrivateExtensionOperation("apply", [ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY]).ok, false);
|
||||
|
||||
const activatePlan = withPlanHash({
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION,
|
||||
planId: "extension-plan-20260715-001",
|
||||
planHash: hash("0"),
|
||||
requestId: activateRequest.requestId,
|
||||
idempotencyKey: activateRequest.idempotencyKey,
|
||||
action: "activate",
|
||||
createdAt: "2026-07-15T18:00:01.000Z",
|
||||
expiresAt: "2026-07-15T18:09:00.000Z",
|
||||
singleUse: true,
|
||||
requiredCapability: ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY,
|
||||
expectedCurrentGeneration: 0,
|
||||
nextGeneration: 1,
|
||||
currentState: inactive,
|
||||
targetState: release,
|
||||
recoveryState: inactive,
|
||||
actions: [
|
||||
"verify_staged_immutable_release",
|
||||
"prepare_sealed_package_tree",
|
||||
"verify_community_package_loader_policy",
|
||||
"quiesce_deploy_run_and_drain_queue",
|
||||
"record_recovery_state",
|
||||
"atomic_switch_current",
|
||||
"force_recreate_main_workers_webhooks_as_version_barrier",
|
||||
"verify_exact_runtime_acceptance",
|
||||
"commit_active_state",
|
||||
"resume_deploy_run",
|
||||
],
|
||||
transition: transition(),
|
||||
acceptance: acceptanceSpec(release),
|
||||
failurePolicy: failurePolicy(),
|
||||
});
|
||||
|
||||
assert.deepEqual(validateEnginePrivateExtensionPlan(activatePlan, { request: activateRequest }), { ok: true, errors: [] });
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlan({ ...activatePlan, nextGeneration: 2 }).errors.includes("nextGeneration_must_increment_current_generation"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlan({ ...activatePlan, transition: { ...transition(), runtimeAction: "restart" } })
|
||||
.errors.includes("transition_policy_mismatch"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlan({
|
||||
...activatePlan,
|
||||
transition: { ...transition(), loaderMode: "custom-extension", loaderPath: "N8N_CUSTOM_EXTENSIONS" },
|
||||
}).errors.includes("transition_policy_mismatch"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlan({
|
||||
...activatePlan,
|
||||
transition: { ...transition(), hotReload: true },
|
||||
}).errors.includes("transition_policy_mismatch"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionPlan({
|
||||
...activatePlan,
|
||||
transition: {
|
||||
...transition(),
|
||||
loaderEnvironment: { ...transition().loaderEnvironment, N8N_REINSTALL_MISSING_PACKAGES: "true" },
|
||||
},
|
||||
}).errors.includes("transition_policy_mismatch"),
|
||||
true,
|
||||
);
|
||||
|
||||
const applyRequest = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION,
|
||||
planId: activatePlan.planId,
|
||||
planHash: activatePlan.planHash,
|
||||
idempotencyKey: activatePlan.idempotencyKey,
|
||||
confirmedAt: "2026-07-15T18:00:02.000Z",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionApplyRequest(applyRequest, {
|
||||
plan: activatePlan,
|
||||
now: "2026-07-15T18:00:02.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}), { ok: true, errors: [] });
|
||||
assert.equal(validateEnginePrivateExtensionApplyRequest(applyRequest, {
|
||||
plan: activatePlan,
|
||||
now: "2026-07-15T18:10:00.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}).errors.includes("plan_expired"), true);
|
||||
|
||||
const receipt = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION,
|
||||
operationId: "extension-operation-20260715-001",
|
||||
planId: activatePlan.planId,
|
||||
planHash: activatePlan.planHash,
|
||||
action: "activate",
|
||||
acceptedAt: "2026-07-15T18:00:02.100Z",
|
||||
state: "queued",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionApplyReceipt(receipt, { plan: activatePlan }), { ok: true, errors: [] });
|
||||
assert.equal(validateEnginePrivateExtensionApplyReceipt({ ...receipt, state: "active" })
|
||||
.errors.includes("apply_receipt_state_must_be_queued"), true);
|
||||
|
||||
const activeOperation = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION,
|
||||
operationId: receipt.operationId,
|
||||
planId: activatePlan.planId,
|
||||
planHash: activatePlan.planHash,
|
||||
action: "activate",
|
||||
state: "active",
|
||||
outcome: "committed",
|
||||
phase: "complete",
|
||||
expectedCurrentGeneration: 0,
|
||||
nextGeneration: 1,
|
||||
targetState: release,
|
||||
recoveryState: inactive,
|
||||
effectiveState: release,
|
||||
runtime: runtime(1, 2, 2),
|
||||
acceptance: acceptanceReport(release, "accepted"),
|
||||
updatedAt: "2026-07-15T18:00:32.000Z",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionOperation(activeOperation), { ok: true, errors: [] });
|
||||
const unexpectedSchema = structuredClone(activeOperation);
|
||||
unexpectedSchema.acceptance.nodeTypes.observed.push("n8n-nodes-ndc.unreviewedNode");
|
||||
assert.equal(validateEnginePrivateExtensionOperation(unexpectedSchema).errors
|
||||
.includes("acceptance.nodeTypes.observed_exact_set_required"), true);
|
||||
|
||||
const automaticRollback = {
|
||||
...activeOperation,
|
||||
state: "rolled-back",
|
||||
outcome: "automatically-rolled-back",
|
||||
phase: "complete",
|
||||
effectiveState: inactive,
|
||||
acceptance: acceptanceReport(inactive, "accepted"),
|
||||
errorCode: "runtime_acceptance_failed",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionOperation(automaticRollback), { ok: true, errors: [] });
|
||||
assert.equal(
|
||||
validateEnginePrivateExtensionOperation({ ...automaticRollback, errorCode: undefined })
|
||||
.errors.includes("errorCode_invalid"),
|
||||
true,
|
||||
);
|
||||
|
||||
const activeStatus = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION,
|
||||
packageName: ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME,
|
||||
generation: 1,
|
||||
health: "ready",
|
||||
currentState: release,
|
||||
previousState: inactive,
|
||||
runtime: runtime(1, 2, 2),
|
||||
acceptance: acceptanceReport(release, "accepted"),
|
||||
updatedAt: "2026-07-15T18:00:33.000Z",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionStatus(activeStatus), { ok: true, errors: [] });
|
||||
|
||||
const rollbackRequest = {
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION,
|
||||
requestId: "extension-rollback-request-20260715-001",
|
||||
idempotencyKey: "extension-rollback-request-20260715-001",
|
||||
action: "rollback",
|
||||
requestedAt: "2026-07-15T18:05:00.000Z",
|
||||
requestExpiresAt: "2026-07-15T18:15:00.000Z",
|
||||
expectedCurrentGeneration: 1,
|
||||
target: { kind: "previous-state", packageName: ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME },
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionPlanRequest(rollbackRequest, {
|
||||
now: "2026-07-15T18:05:01.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}), { ok: true, errors: [] });
|
||||
|
||||
const rollbackPlan = withPlanHash({
|
||||
schemaVersion: ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION,
|
||||
planId: "extension-rollback-plan-20260715-001",
|
||||
planHash: hash("0"),
|
||||
requestId: rollbackRequest.requestId,
|
||||
idempotencyKey: rollbackRequest.idempotencyKey,
|
||||
action: "rollback",
|
||||
createdAt: "2026-07-15T18:05:01.000Z",
|
||||
expiresAt: "2026-07-15T18:14:00.000Z",
|
||||
singleUse: true,
|
||||
requiredCapability: ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY,
|
||||
expectedCurrentGeneration: 1,
|
||||
nextGeneration: 2,
|
||||
currentState: release,
|
||||
targetState: inactive,
|
||||
recoveryState: release,
|
||||
actions: [
|
||||
"verify_previous_activation_state",
|
||||
"verify_community_package_loader_policy",
|
||||
"quiesce_deploy_run_and_drain_queue",
|
||||
"record_recovery_state",
|
||||
"atomic_switch_current_to_previous",
|
||||
"force_recreate_main_workers_webhooks_as_version_barrier",
|
||||
"verify_exact_runtime_acceptance",
|
||||
"commit_rolled_back_state",
|
||||
"resume_deploy_run",
|
||||
],
|
||||
transition: transition(),
|
||||
acceptance: acceptanceSpec(inactive),
|
||||
failurePolicy: failurePolicy(),
|
||||
});
|
||||
assert.deepEqual(validateEnginePrivateExtensionPlan(rollbackPlan, { request: rollbackRequest }), { ok: true, errors: [] });
|
||||
|
||||
const explicitRollbackOperation = {
|
||||
...activeOperation,
|
||||
operationId: "extension-operation-rollback-20260715-001",
|
||||
planId: rollbackPlan.planId,
|
||||
planHash: rollbackPlan.planHash,
|
||||
action: "rollback",
|
||||
state: "rolled-back",
|
||||
outcome: "explicitly-rolled-back",
|
||||
expectedCurrentGeneration: 1,
|
||||
nextGeneration: 2,
|
||||
targetState: inactive,
|
||||
recoveryState: release,
|
||||
effectiveState: inactive,
|
||||
runtime: runtime(2, 2, 2),
|
||||
acceptance: acceptanceReport(inactive, "accepted"),
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionOperation(explicitRollbackOperation), { ok: true, errors: [] });
|
||||
|
||||
const quarantinedStatus = {
|
||||
...activeStatus,
|
||||
health: "quarantined",
|
||||
activeOperationId: "extension-operation-failed-20260715-001",
|
||||
acceptance: acceptanceReport(release, "rejected"),
|
||||
errorCode: "automatic_rollback_failed",
|
||||
};
|
||||
assert.deepEqual(validateEnginePrivateExtensionStatus(quarantinedStatus), { ok: true, errors: [] });
|
||||
|
||||
const pathInjection = structuredClone(activateRequest);
|
||||
pathInjection.target.releaseId = "../../runtime";
|
||||
assert.equal(validateEnginePrivateExtensionPlanRequest(pathInjection, {
|
||||
now: "2026-07-15T18:00:01.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}).errors.includes("target.releaseId_invalid"), true);
|
||||
|
||||
const commandInjection = { ...activateRequest, command: "docker exec runtime npm install" };
|
||||
assert.equal(validateEnginePrivateExtensionPlanRequest(commandInjection, {
|
||||
now: "2026-07-15T18:00:01.000Z",
|
||||
grantedCapabilities: manage,
|
||||
}).errors.includes("enginePrivateExtensionPlanRequest.command_not_allowed"), true);
|
||||
|
||||
console.log("external-provider Engine private-extension contract: ok");
|
||||
|
||||
function transition() {
|
||||
return {
|
||||
mountMode: "read-only",
|
||||
loaderMode: "community-package",
|
||||
loaderPath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
|
||||
loaderEnvironment: {
|
||||
N8N_COMMUNITY_PACKAGES_ENABLED: "true",
|
||||
N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: "false",
|
||||
N8N_REINSTALL_MISSING_PACKAGES: "false",
|
||||
},
|
||||
quiesceMode: "block-deploy-run-and-drain-queue",
|
||||
stateSwitch: "atomic-current-previous",
|
||||
runtimeAction: "force-recreate",
|
||||
scope: "main-workers-webhooks",
|
||||
requireUniformGeneration: true,
|
||||
hotReload: false,
|
||||
preserveCredentials: true,
|
||||
};
|
||||
}
|
||||
|
||||
function failurePolicy() {
|
||||
return {
|
||||
mode: "automatic-rollback",
|
||||
rollbackFailureOutcome: "quarantined",
|
||||
preserveImmutableRelease: true,
|
||||
preserveCredentials: true,
|
||||
};
|
||||
}
|
||||
|
||||
function acceptanceSpec(state) {
|
||||
return {
|
||||
mode: "exact",
|
||||
nodeTypes: state.kind === "release" ? [...ENGINE_PRIVATE_EXTENSION_NODE_TYPES] : [],
|
||||
credentialSchemas: state.kind === "release" ? [...ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES] : [],
|
||||
requireUniformGeneration: true,
|
||||
};
|
||||
}
|
||||
|
||||
function acceptanceReport(state, status) {
|
||||
const nodes = state.kind === "release" ? [...ENGINE_PRIVATE_EXTENSION_NODE_TYPES] : [];
|
||||
const credentials = state.kind === "release" ? [...ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES] : [];
|
||||
return {
|
||||
state: status,
|
||||
nodeTypes: { expected: nodes, observed: status === "pending" ? [] : nodes },
|
||||
credentialSchemas: { expected: credentials, observed: status === "pending" ? [] : credentials },
|
||||
uniformGeneration: status === "accepted",
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(generation, expectedInstances, readyInstances) {
|
||||
return { mode: "force-recreate", generation, expectedInstances, readyInstances };
|
||||
}
|
||||
|
||||
function withPlanHash(plan) {
|
||||
plan.planHash = computeEnginePrivateExtensionPlanHash(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
function hash(character) {
|
||||
return "sha256:" + character.repeat(64);
|
||||
}
|
||||
Reference in New Issue
Block a user