Author SHA1 Message Date
Codex 49813d7839 fix(map-gateway): unlock warm layers and local station search 2026-08-09 20:27:04 +03:00
Codex 07224c6f0d CHORE - NAS DEPLOY: add Foundry sector workspace artifact 2026-08-08 14:09:56 +03:00
Codex 6352a26020 CHORE - NAS DEPLOY: артефакт Tasker UI recovery 2026-08-08 11:48:47 +03:00
Codex e868fd4bcf FEAT - DEPLOY: артефакты Tasker UI и редактирования комментариев 2026-08-06 18:02:29 +03:00
Codex cd5bf2a6d9 feat(deploy): package Foundry map grid LOD 2026-08-05 19:37:53 +03:00
Codex 4f75f57177 test(deploy): preserve empty Foundry map filters 2026-08-05 19:37:53 +03:00
Codex b48b6dbfc9 feat(gelios): expand safe B2 telemetry registry 2026-08-05 15:20:23 +03:00
Codex 0688516003 docs(device-edge): add B2 public pilot runbook 2026-08-04 12:04:54 +03:00
Codex 96227461dd docs(device-edge): record isolated ingress status 2026-08-04 11:55:17 +03:00
Codex 1eb6c462e3 feat(device-edge): add fail-closed ingress admission 2026-08-04 11:53:18 +03:00
Codex 3bb5e6dc27 feat(device-edge): add isolated B2 ingress domain 2026-08-04 11:39:01 +03:00
Codex b1a5a26b9a TEST - NAS DEPLOY: проверка состава пакетов Ops BIM gateway 2026-07-30 07:39:19 +03:00
Codex 558940f691 CHORE - NAS DEPLOY: пакетирование Ops BIM gateway artifacts 2026-07-30 07:36:44 +03:00
Codex 9d8638b8d2 CHORE - DEPLOY: deterministic BIM and Tasker CAD artifacts 2026-07-29 17:58:11 +03:00
Codex a3308c9b3d fix(device-plane): constrain B2 discovery to loopback 2026-07-26 01:05:14 +03:00
Codex 2b1795509b feat(device-plane): enable B2 discovery ingress 2026-07-26 00:52:31 +03:00
Codex 3c538ad98c fix(deploy): publish device plane loopback health 2026-07-26 00:09:55 +03:00
Codex aca47c6143 fix(deploy): reconcile device plane foundation 2026-07-25 23:11:14 +03:00
Codex 1102e25e6e fix(deploy): clarify device postgres bootstrap plan 2026-07-25 22:17:05 +03:00
Codex e217723784 feat(device-plane): add fail-closed deploy foundation 2026-07-25 21:29:05 +03:00
Codex e9e03143cd feat(engine): register MCP profiles and provider transitions 2026-07-25 15:39:23 +03:00
Codex 951b884c4b feat(map): cache and search reference stations 2026-07-25 15:37:53 +03:00
Codex fe274872e4 feat(provider): version gelios items response envelope 2026-07-24 18:52:40 +03:00
Codex f0a4fb43c5 feat(deploy): add execution plan sandbox runtime transition 2026-07-24 18:17:05 +03:00
Codex a9fe5f44e3 feat(deploy): register L1 credential provenance successor 2026-07-24 17:40:34 +03:00
Codex c19b0789c7 feat(deploy): register L1 credential reuse transition 2026-07-24 17:20:01 +03:00
Codex 34769af350 feat(deploy): register normalized identity transition 2026-07-24 12:04:40 +03:00
Codex 659f98ad8f chore(deploy): add unit identity rollout artifacts 2026-07-24 11:43:26 +03:00
Codex abebb9fac6 feat(provider): publish full unit identity product 2026-07-24 11:42:41 +03:00
Codex 8cc917508e feat(deploy): package classified subject aspect transition 2026-07-24 09:32:02 +03:00
Codex eab47bc1fa feat(provider-contract): add classified unit contact aspect 2026-07-24 09:31:34 +03:00
Codex f02a1d4125 feat(deploy): register execution plan module ownership transition 2026-07-23 23:27:36 +03:00
Codex 73364b4acb feat(provider-contract): add Gelios v9 telemetry projection 2026-07-23 23:27:31 +03:00
Codex 59f9fc2b26 Register Engine telemetry runtime deploy successor 2026-07-23 22:31:01 +03:00
Codex 25b12d77e4 feat(deploy): register Engine MCP materialization transition 2026-07-23 20:37:31 +03:00
Codex 4cf3a83ef1 feat(provider-contract): define materializable L2 graph blueprints 2026-07-23 20:22:22 +03:00
Codex a5a5644dbf feat(provider-contract): compile governed L2 execution plans 2026-07-23 19:27:01 +03:00
Codex 6f9da1b677 fix(deploy): attest Engine telemetry gateway transition 2026-07-23 16:24:52 +03:00
Codex f97a9d924d feat(deploy): register Engine telemetry catalog transition 2026-07-23 14:55:56 +03:00
Codex e4383b6162 feat(deploy): register Engine MCP profile decoder transition 2026-07-23 14:38:12 +03:00
Codex 50cba59bde Add exact Engine L2 reconciliation transition 2026-07-23 13:10:11 +03:00
Codex ad3760e767 Add reproducible Engine L2 deployment artifact 2026-07-23 12:25:05 +03:00
Codex 2a30e1e991 test(deploy): pin Foundry unit profile policy artifact 2026-07-23 09:44:02 +03:00
Codex ec45d09509 fix(deploy): derive validation descriptor from current lineage 2026-07-23 09:07:06 +03:00
Codex 9db0de540d fix(deploy): transition node intelligence source atomically 2026-07-23 08:58:47 +03:00
Codex 6c764d0d28 fix(deploy): route private validation slice canonically 2026-07-23 08:51:19 +03:00
Codex 93eb13219a feat(deploy): promote private node reconciliation 2026-07-23 08:43:36 +03:00
Codex 23919e384e feat(deploy): promote publish-path provider authority 2026-07-23 08:16:11 +03:00
Codex 4cdc78d545 feat(deploy): add Gelios profile authority transition 2026-07-23 07:48:17 +03:00
Codex e843bbda66 feat(provider): add Gelios unit profile product 2026-07-22 23:36:57 +03:00
Codex 2def90fa33 fix(deploy): include provider capability catalog 2026-07-22 19:16:26 +03:00
Codex 92070f21e8 feat(provider): catalogue complete Gelios data surface 2026-07-22 19:15:12 +03:00
Codex fdda153595 feat(deploy): seal Gelios telemetry authority transition 2026-07-22 13:06:32 +03:00
Codex 4402c9ed25 feat(data-plane): add bounded Gelios telemetry contract 2026-07-22 11:47:24 +03:00
Codex a9b8d71968 fix(data-plane): accept exact replacement replays 2026-07-21 23:11:35 +03:00
Codex 45884cd4dc test(deploy): seal zone v2 consumer policy artifact 2026-07-21 15:21:00 +03:00
Codex 358b259aac fix(n8n): make replacement replay idempotent 2026-07-21 15:06:59 +03:00
Codex bc23c550db fix(deploy): render new authority paths in plan 2026-07-21 14:27:37 +03:00
Codex 77bf8036d2 feat(deploy): register Depttrans zone authority 2026-07-21 14:20:06 +03:00
Codex 9bebd2f504 feat(deploy): register n8n publisher replace v2 2026-07-21 13:39:15 +03:00
Codex 92d292ce87 fix(data-plane): preserve immutable zone product v1 2026-07-21 12:48:49 +03:00
Codex 343812b90d feat(map): ingest audited department zone snapshot 2026-07-21 12:02:11 +03:00
Codex 3446f3edc2 feat(platform): add replaceable geozone data layer 2026-07-20 21:48:27 +03:00
Codex 8a7465cf0e feat(platform): complete the Gelios external data loop 2026-07-20 20:45:05 +03:00
Codex def9a24e0d feat(edp): provision Foundry reader grants 2026-07-19 15:03:48 +03:00
Codex 847a08da93 feat(ops): add workspace MCP deployment overlays 2026-07-19 12:34:06 +03:00
Codex 56b766b23b chore(deploy): preserve Engine auth recovery builders 2026-07-19 12:34:00 +03:00
Codex d85912b9d7 feat(deploy): add Engine ontology SDK transition 2026-07-19 12:33:56 +03:00
Codex f67c49bc4d feat(provider): add Gelios SDK package v2 2026-07-19 12:33:50 +03:00
Codex 95446bdc24 fix(edp): resolve managed reader source scope 2026-07-19 12:28:00 +03:00
Codex 0611a88971 feat(n8n): add rotating provider access credential 2026-07-19 10:11:56 +03:00
Codex 3c5d8f6cef feat(platform): add managed data product history plane 2026-07-18 14:38:06 +03:00
Codex 02816c4352 feat(deploy): add engine node intelligence transition 2026-07-17 21:50:50 +03:00
Codex 31e078d6e5 docs(platform): align connector, EDP, and deploy canon 2026-07-17 18:09:55 +03:00
Codex 2dd6e33a54 feat(deploy): extend canon for managed EDP and Engine grants 2026-07-17 18:09:39 +03:00
Codex a0a4d36fa2 feat(data-plane): add signed managed writer bindings 2026-07-17 18:09:15 +03:00
Codex 3415674e76 feat(connectors): version provider packages and ontology contracts 2026-07-17 18:08:36 +03:00
Codex 567f1550ab feat(deploy): register managed platform runtime components 2026-07-16 02:25:31 +03:00
Codex fedaf24098 chore(proxy-contur): preserve legacy Contour compatibility service 2026-07-16 02:24:31 +03:00
Codex 7b55d887bc feat(map): add cache-first Cesium gateway and AMD egress 2026-07-16 02:23:56 +03:00
Codex 59e9c92415 feat(engine): add private NDC nodes and ontology bridge 2026-07-16 02:23:45 +03:00
Codex 569b8762e6 feat(data-plane): add provider contracts and ontology delivery 2026-07-16 02:23:34 +03:00
Codex e527812826 feat(map): add persistent gateway and ontology package 2026-07-13 17:14:34 +03:00
Codex d196d4b0c7 Narrow DC CMS source deploy runtime 2026-07-06 11:58:52 +03:00
Codex 7ef0ca8eab Add DC CMS site workspace deploy component 2026-07-06 11:55:08 +03:00
Codex a31b679ab9 Print compose logs on deploy failure 2026-07-06 10:44:36 +03:00
Codex 7460258228 Wait for DC CMS auth bootstrap in deploy runner 2026-07-06 10:32:24 +03:00
Codex 099796a61d Register DC CMS in deploy runner 2026-07-06 10:09:46 +03:00
Codex a7c6c34c82 feat(ai-workspace): support SEO codex provider bridge 2026-07-03 08:43:37 +03:00
482 changed files with 182752 additions and 180 deletions
+3
View File
@@ -16,6 +16,9 @@ build/
.next/
coverage/
# canonical deploy packages are transferred through the NAS inbox, not Git
infra/deploy-artifacts/
# logs
*.log
logs/
+4
View File
@@ -46,3 +46,7 @@ Notification Core живёт в `services/notification-core` как отдель
AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента.
Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement.
Map Gateway живёт в `services/map-gateway` как общий platform boundary для provider credentials, безопасного asset endpoint exchange, tile/3D Tiles proxy и persistent offline TileCache. Runtime cache не является source artifact и хранится в named volume/object storage, а не в Git.
Внешние бизнес-поставщики подключаются по `packages/external-provider-contract`: adapter конкретного API принадлежит изолированному NDC L2 workflow, а Platform даёт один provider-neutral External Data Plane для raw retention, canonical facts, current/history projections и scoped read products. Connection instance несёт tenant scope, credential reference и collection profile, но не secret value. Provider/domain mapping, entity filtering и renderer logic не попадают в Platform Data Plane. Provider-issued credentials сохраняются в native NDC L2 Credentials; внутренний scoped publish grant генерируется и сохраняется generic control plane внутри native credential boundary, EDP получает только digest, а MCP видит только opaque reference/status. Legacy plaintext provisioning и managed digest-only ensure включаются независимо и по умолчанию закрыты. Подробный канон — `docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.
+12
View File
@@ -0,0 +1,12 @@
.git
.DS_Store
.env
.env.*
docs
node_modules
**/test
**/*.log
**/*.prev-*
**/*.next-*
runtime
secrets
+63
View File
@@ -0,0 +1,63 @@
# NDC Device Plane
`device-plane` is the provider-neutral runtime boundary for physical devices.
It is intentionally separate from Foundry, Engine L2, External Data Plane and
the preserved Gelios integration.
Recorded runtime status: the private Synology foundation and the isolated Mini
IPvlan relay are running through their reviewed artifacts. Synology has no
public device listener; the Mini relay is LAN-addressable only and router/NAT
exposure remains a separate manual gate. The admission-gate source below is
prepared and staged, but has not yet been applied.
- `packages/device-protocol-contract` owns safe discovery and presentation
contracts.
- `packages/arusnavi-b2-adapter` owns the first model-profile evidence and a
fail-closed framing boundary.
- `services/device-control-core` owns the initial PostgreSQL schema, health
boundary and disabled-by-default quarantine ingest.
- `services/device-gateway` owns a disabled-by-default, loopback-only TCP
evidence listener that sends no bytes and extracts no identifier until the
official framing contract is known.
- `docker-compose.device-plane.yml` publishes only loopback health endpoints
and keeps the raw TCP listener unpublished.
- No device command can be built or sent.
- No real IMEI, ICCID, password, packet or provider credential is stored in
this source tree.
The planned runtime services are:
- `device-control-core`: contours, discoveries, devices, bindings, policy and
audit;
- `device-gateway`: raw TCP sessions, bounded codecs and presence;
- `device-postgres`: private persistent state.
`services/device-edge-relay` is a separate, disabled-by-default runtime for the
dedicated Debian ingress host. It is an L4 byte relay: it neither decodes
ARUSNAVI B2, acknowledges a device nor holds a Core token, database credential
or command capability. The implemented backhaul forwards only through a
restricted outbound SSH path to the Synology Gateway loopback listener. The
reviewed ingress target gives only this container a fixed IPvlan L2 address on
the Mini's single Ethernet link, so the host Amnezia full tunnel stays intact
and Docker publishes no host port. The fixed relay address is
`192.168.71.253`, explicitly outside the documented Deco DHCP pool
`192.168.68.50``192.168.71.250`. See `docs/IMPLEMENTATION_BASELINE.md` for
the remaining activation gates. The staged admission update keeps the relay
opaque but requires a public IPv4 source and bounds its source table and bytes
per direction; it does not enable router/NAT exposure.
The Foundry `Device Manager` is a canonical page template using a server-owned
`device-plane-control` binding. It is not a service in this directory.
Run the foundation tests:
```bash
npm test
```
See [IMPLEMENTATION_BASELINE.md](docs/IMPLEMENTATION_BASELINE.md) for the
placement, security and rollout contract.
The canonical runner registry and deterministic artifact builder live in
`../infra/deploy-runner`. The runner must be separately promoted and verified
before any Device Plane artifact is staged or planned.
@@ -0,0 +1,39 @@
{
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
"mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12",
"component": "device-edge",
"selectedServices": [
"device-edge-relay"
],
"preservedServices": [
"device-edge-backhaul",
"tailnet"
],
"composeProject": "nodedc-device-edge",
"composeFiles": [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml"
],
"parentInterface": "enp1s0f0",
"lanSubnet": "192.168.68.0/22",
"lanGateway": "192.168.68.1",
"ingressIpv4": "192.168.71.253",
"ingressIpv4Approval": "approved-outside-dhcp-pool",
"ingressNetwork": "nodedc-device-edge-ingress",
"deviceTcpListen": "192.168.71.253:9921",
"hostPortPublication": "disabled",
"healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921",
"sourceAdmission": "public-ipv4-only",
"maxTrackedSourceAddresses": 2048,
"maxBytesPerDirection": 262144,
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation"
}
@@ -0,0 +1,25 @@
{
"schemaVersion": "nodedc.device-edge.backhaul.v1",
"mode": "private-tailnet-ssh-local-forward",
"runtimeHost": "ndcmini12",
"selectedServices": [
"device-edge-backhaul"
],
"preservedServices": [
"device-edge-relay",
"tailnet"
],
"tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055",
"sshTarget": "100.109.216.21:2222",
"sshUser": "device-backhaul",
"localForward": "0.0.0.0:19921",
"permittedRemoteTarget": "127.0.0.1:9921",
"hostPortPublication": "disabled",
"deviceIngress": "disabled",
"protocolInspection": "disabled",
"commandTransport": "disabled",
"privateKey": "runtime-only-read-only",
"knownHosts": "runner-prepared-exact-ed25519",
"routerNatFirewall": "unchanged",
"gelios": "untouched"
}
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1",
"mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12",
"component": "device-edge",
"selectedServices": [
"device-edge-relay"
],
"preservedServices": [
"device-edge-backhaul",
"tailnet"
],
"composeProject": "nodedc-device-edge",
"composeFiles": [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml"
],
"parentInterface": "enp1s0f0",
"lanSubnet": "192.168.68.0/22",
"lanGateway": "192.168.68.1",
"ingressIpv4": "192.168.71.253",
"ingressIpv4Approval": "approved-outside-dhcp-pool",
"ingressNetwork": "nodedc-device-edge-ingress",
"deviceTcpListen": "192.168.71.253:9921",
"hostPortPublication": "disabled",
"healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921",
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-predecessor-relay-remove-unused-ingress-network"
}
@@ -0,0 +1,30 @@
{
"schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1",
"mode": "verified-b2-loopback-discovery-only",
"predecessorPatchId": "device-plane-foundation-network-publication-20260725-003",
"predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
"sourceAction": "publish-verified-b2-loopback-discovery-source",
"runtimeAction": "build-and-recreate-stateless-services",
"selectedServices": [
"device-control-core",
"device-gateway"
],
"preservedServices": [
"device-postgres"
],
"privateNetwork": "nodedc-device-plane-private",
"controlNetwork": "nodedc-device-plane-control",
"publishedPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
"127.0.0.1:9921:9921/tcp"
],
"protocolProfile": "arusnavi.b2.internal.v1",
"framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "restore-source-and-predecessor-stateless-runtime"
}
@@ -0,0 +1,23 @@
{
"schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
"mode": "failed-b2-loopback-build-reconciliation",
"failedPatchId": "device-plane-b2-discovery-loopback-20260801-003",
"failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
"failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
"sourceAction": "publish-reconciliation-marker-only",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"expectedLoopbackPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121"
],
"closedPort": "127.0.0.1:9921/tcp",
"databaseVolume": "nodedc-device-plane-postgres-data",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "marker-only-runtime-unchanged"
}
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
"mode": "failed-backhaul-target-to-loopback-tailnet-serve",
"failedPatchId": "device-plane-backhaul-target-20260803-001",
"failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
"failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
"predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006",
"predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
"sourceAction": "publish-loopback-backhaul-target-source",
"runtimeAction": "build-create-target-and-register-private-tailnet-serve",
"composeOverlay": "docker-compose.device-plane.backhaul-target.yml",
"selectedServices": [
"device-backhaul-target"
],
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"loopbackListenAddress": "127.0.0.1",
"listenPort": 2222,
"tailnetAddress": "100.109.216.21",
"tailnetExposure": "tailscale-serve-private",
"tailscaleServeTarget": "tcp://127.0.0.1:2222",
"permittedTarget": "127.0.0.1:9921",
"networkMode": "host",
"dockerPortPublication": "disabled",
"routerNatFirewall": "unchanged",
"edgePublicIngress": "disabled",
"funnel": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"databaseVolume": "nodedc-device-plane-postgres-data",
"runtimeTrust": "runner-managed",
"rollback": "remove-tailnet-serve-target-and-restore-source"
}
@@ -0,0 +1,24 @@
{
"schemaVersion": "nodedc.device-plane.foundation-network-publication.v1",
"mode": "failed-foundation-network-publication-correction",
"failedRecoveryPatchId": "device-plane-foundation-recovery-20260725-002",
"failedRecoveryArtifactSha256": "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
"failedRecoveryBackupId": "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
"sourceAction": "publish-network-corrected-foundation-source",
"runtimeAction": "recreate-stateless-services-no-build",
"selectedServices": [
"device-control-core",
"device-gateway"
],
"preservedServices": [
"device-postgres"
],
"privateNetwork": "nodedc-device-plane-private",
"controlNetwork": "nodedc-device-plane-control",
"publishedLoopbackPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "restore-partial-source-and-internal-only-stateless-runtime"
}
@@ -0,0 +1,16 @@
{
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
"mode": "failed-foundation-live-runtime-adoption",
"failedPatchId": "device-plane-foundation-20260725-001",
"failedArtifactSha256": "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
"backupId": "device-plane-device-plane-foundation-20260725-001-20260725-223441",
"sourceAction": "publish-exact-failed-artifact-source",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "source-only-runtime-unchanged"
}
@@ -0,0 +1,8 @@
{
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
"service": "device-postgres",
"volume": "nodedc-device-plane-postgres-data",
"mode": "create-if-absent",
"ordinaryApplicationSelection": "forbidden",
"rollbackVolumePolicy": "preserve"
}
@@ -0,0 +1,82 @@
services:
device-edge-backhaul:
build:
context: .
dockerfile: services/device-edge-backhaul/Dockerfile
image: nodedc/device-edge-backhaul:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
volumes:
- ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro
- ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro
command:
- -N
- -T
- -p
- "2222"
- -i
- /run/keys/edge-to-synology
- -L
- 0.0.0.0:19921:127.0.0.1:9921
- -o
- BatchMode=yes
- -o
- PasswordAuthentication=no
- -o
- KbdInteractiveAuthentication=no
- -o
- PubkeyAuthentication=yes
- -o
- IdentitiesOnly=yes
- -o
- StrictHostKeyChecking=yes
- -o
- UserKnownHostsFile=/run/trust/known_hosts
- -o
- UpdateHostKeys=no
- -o
- ExitOnForwardFailure=yes
- -o
- ServerAliveInterval=15
- -o
- ServerAliveCountMax=3
- -o
- TCPKeepAlive=yes
- -o
- LogLevel=VERBOSE
- -o
- ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p
- device-backhaul@100.109.216.21
networks:
- device-edge-private
- device-edge-tailnet
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32
healthcheck:
test:
- CMD
- nc
- -z
- -w
- "3"
- 127.0.0.1
- "19921"
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-edge-private:
name: nodedc-device-edge-private
external: true
device-edge-tailnet:
name: nodedc-device-edge-tailnet
external: true
@@ -0,0 +1,35 @@
services:
device-edge-relay:
environment:
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"
DEVICE_EDGE_RELAY_TCP_HOST: 0.0.0.0
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul
DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
networks:
device-edge-private:
gw_priority: 0
device-edge-ingress:
ipv4_address: 192.168.71.253
gw_priority: 100
networks:
device-edge-private:
name: nodedc-device-edge-private
device-edge-ingress:
name: nodedc-device-edge-ingress
driver: ipvlan
driver_opts:
parent: enp1s0f0
ipvlan_mode: l2
ipam:
config:
- subnet: 192.168.68.0/22
gateway: 192.168.68.1
@@ -0,0 +1,44 @@
services:
device-edge-relay:
build:
context: .
dockerfile: services/device-edge-relay/Dockerfile
image: nodedc/device-edge-relay:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1
DEVICE_EDGE_RELAY_HEALTH_PORT: "18221"
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
networks:
device-edge-private: {}
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-edge-private:
name: nodedc-device-edge-private
internal: true
@@ -0,0 +1,39 @@
services:
device-backhaul-target:
image: nodedc/device-backhaul-target:local
pull_policy: never
restart: unless-stopped
network_mode: host
read_only: true
tmpfs:
- /run:size=8m,mode=0755
- /tmp:size=8m,mode=1777
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key
target: /run/nodedc-secrets/ssh_host_ed25519_key
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys
target: /run/nodedc-secrets/authorized_keys
read_only: true
bind:
create_host_path: false
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
- SYS_CHROOT
healthcheck:
test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
@@ -0,0 +1,158 @@
services:
device-postgres:
image: postgres:16-alpine
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: device_plane
POSTGRES_USER: device_plane
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
volumes:
- type: volume
source: device-plane-postgres-data
target: /var/lib/postgresql/data
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
networks:
- device-plane-private
healthcheck:
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-control-core:
image: nodedc/device-control-core:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
HOST: 0.0.0.0
PORT: "18120"
DEVICE_DATABASE_HOST: device-postgres
DEVICE_DATABASE_PORT: "5432"
DEVICE_DATABASE_NAME: device_plane
DEVICE_DATABASE_USER: device_plane
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
DEVICE_DATABASE_POOL_SIZE: "10"
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
target: /run/nodedc-secrets/gateway-core-token
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
target: /run/nodedc-secrets/identifier-pepper
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18120:18120"
networks:
- device-plane-private
- device-plane-control
depends_on:
device-postgres:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-gateway:
image: nodedc/device-gateway:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
DEVICE_GATEWAY_HEALTH_PORT: "18121"
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
DEVICE_GATEWAY_TCP_PORT: "9921"
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
DEVICE_GATEWAY_MAX_SESSIONS: "100"
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
target: /run/nodedc-secrets/gateway-core-token
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18121:18121"
- "127.0.0.1:9921:9921"
networks:
- device-plane-private
- device-plane-control
depends_on:
device-control-core:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-plane-private:
name: nodedc-device-plane-private
internal: true
device-plane-control:
name: nodedc-device-plane-control
driver: bridge
internal: false
driver_opts:
com.docker.network.bridge.enable_ip_masquerade: "false"
volumes:
device-plane-postgres-data:
name: nodedc-device-plane-postgres-data
@@ -0,0 +1,106 @@
# Device Edge B2 public pilot runbook
Status: router/NAT is **not configured** by this document. It is a one-pilot,
human-operated exposure gate for the already accepted Mini relay. It never
changes Synology, Gelios, VPN, Device Plane command transport or device
ownership.
## Exact traffic path
```text
ARUSNAVI B2 pilot
-> public IPv4 : TCP/9921
-> provider router : TCP/9921 -> 192.168.1.151:9921
-> Deco X55 : TCP/9921 -> 192.168.71.253:9921
-> Mini IPvlan relay
-> restricted private backhaul
-> Synology Gateway 127.0.0.1:9921
```
The observed Deco WAN is `192.168.1.151` with gateway `192.168.0.1`; it is an
RFC1918 address. Therefore this is a double-NAT topology. A Deco rule alone
cannot make the tracker reachable from the internet.
## Immutable safety boundary
- Forward **TCP only**, external and internal port `9921`.
- Deco target is exactly `192.168.71.253`, never the Mini host
`192.168.68.54` and never Synology.
- Do not enable DMZ, UPnP, port ranges, UDP, 443 forwarding or any catch-all
rule.
- Do not remove, replace or edit the Gelios monitoring route.
- The relay accepts connections only from a public IPv4 source, keeps
quarantine-only discovery and has no command transport. A LAN client will
intentionally be rejected; that is not a test failure.
- The rule must be deleted again if the single pilot is abandoned or the
expected quarantine evidence is not obtained.
## Gate 1 — DNS and public address
1. Determine the actual public IPv4 on the provider-facing edge. Do not use
`192.168.0.1`, `192.168.1.151`, `192.168.68.1` or `192.168.71.253` as a
DNS answer: all are private addresses.
2. Point `device.nodedc.ru` to that public IPv4 only if the tracker UI accepts
a hostname. Otherwise configure the public IPv4 directly.
3. If the provider router has no public WAN IPv4, or an internet check still
cannot reach it after both rules below, stop. This is provider CGNAT/bridge
territory: request a public IPv4 or a bridge/forwarding option from the
provider. Do not introduce a VPS as an unreviewed workaround.
## Gate 2 — provider router
On the upstream/provider router, create one port-forward rule:
| Field | Value |
| --- | --- |
| Name | `NDC B2 pilot to Deco` |
| Protocol | `TCP` |
| External port | `9921` |
| Target address | `192.168.1.151` |
| Target port | `9921` |
Save only that rule. It targets the Deco WAN address, not a service host.
## Gate 3 — Deco X55
In the Deco mobile app: **More → Advanced → NAT Forwarding → Port Forwarding →
Add**. Create exactly:
| Field | Value |
| --- | --- |
| Name | `NDC B2 pilot` |
| Protocol | `TCP` |
| External port | `9921` |
| Internal IP | `192.168.71.253` |
| Internal port | `9921` |
If the app forces a client selection, select the IPvlan relay only if its
address is shown as `192.168.71.253`. Do not select the Mini host or any NAS.
If the UI will not accept the fixed IPvlan address, stop and record that fact;
do not substitute a DHCP address.
## Gate 4 — ARUSNAVI B2 route
Use one known pilot B2 and one unused monitoring-server slot. Configure an
additional route with the documented **INTERNAL** protocol and the public
hostname/IP from Gate 1, TCP port `9921`. Preserve the existing Gelios route in
its current slot. The ARUSNAVI account password stays in the operator surface;
it is not entered into Foundry, Device Plane or the relay.
The device IMEI observed in `HEADER2` is a claimed identifier only. It becomes
a quarantine discovery, not an owned device and never a command target.
## Acceptance and failure handling
The first valid HEADER/PACKAGE through the pilot route must produce a masked,
quarantine-only discovery in Device Control Core and the existing Gelios map
path must continue independently. Do not issue a device command.
On any unexpected behavior, remove the two NAT rules in reverse order:
1. remove the Deco `NDC B2 pilot` rule;
2. remove the provider-router `NDC B2 pilot to Deco` rule.
This ends external reachability while leaving the Mini, VPN, backhaul,
Synology and Gelios unchanged. Do not use a LAN port probe as acceptance: the
relay correctly rejects private source addresses.
@@ -0,0 +1,277 @@
# Device Plane Implementation Baseline
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
Synology. The accepted foundation has public ingress and discovery ingest
disabled. The next additive transition enables only an authenticated,
quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
remains disabled.
## Product boundary
The Device Manager user interface is a canonical Foundry Page Library
template. Foundry owns page instances, layout, presentation and an opaque
`device-plane-control` binding. It does not own device records, credentials,
raw protocol or command delivery.
The independent NDC Device Plane owns physical-device state and direct
connections:
```text
Foundry Device Manager Page
|
| device-plane-control (typed server boundary)
v
Device Control Core <-> Device PostgreSQL
|
v
Device Gateway <-> physical devices
```
The isolated ingress placement replaces the direct physical-device arrow when
the raw route must not terminate on the multi-service Synology:
```text
ARUSNAVI B2 device
|
| raw TCP 9921 (future, separately approved)
v
Device Edge Relay on dedicated mini
|
| outbound restricted SSH local-forward; opaque byte stream only
v
Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core
```
The Edge Relay owns neither protocol acknowledgement nor device identity. It
does not receive the Gateway/Core token, PostgreSQL credentials, Foundry
bindings or any command capability. The Synology Gateway remains the sole B2
codec and acknowledgement owner.
Engine L2 may consume safe decoded observations and build workflows/Data
Products. It does not own TCP sessions, secrets or the command transport.
## Preserved production path
The existing Gelios -> Engine L2 -> External Data Plane -> Foundry Map path is
outside this implementation slice. Its credentials, workflows, Data Products,
bindings and map presentation must not be changed or restarted by a Device
Plane artifact.
The first B2 pilot adds an NDC server route in parallel and keeps the existing
Gelios route unchanged.
## Source and runtime placement
Source:
```text
platform/device-plane/
packages/device-protocol-contract/
packages/arusnavi-b2-adapter/
services/device-control-core/
services/device-gateway/
services/device-edge-relay/
docker-compose.device-plane.yml
docker-compose.device-edge.yml
```
Planned Synology runtime:
```text
/volume1/docker/nodedc-device-plane
```
Planned Compose project and services:
```text
nodedc-device-plane
device-control-core
device-gateway
device-postgres
```
`device-postgres` is a private persistent prerequisite. Application overlays
must never force-recreate it or its volume.
The canonical runner selects only `device-control-core` and `device-gateway`
with `--no-deps`. Its health acceptance is scoped to the selected services and
requires the fail-closed fields to remain disabled. A failed first activation
removes only candidate stateless services and never requests volume removal.
Rollback now records an explicit pre-apply service inventory in the backup;
the existence of the shared Compose file does not imply that Core or Gateway
existed before apply.
The exact foundation recovery validates the failed archive, journal, backup,
partial live source and observed healthy image/container generations. It then
publishes the matching source and performs read-only runtime acceptance. It
does not build, restart, recreate or remove any service.
## Network boundary
The accepted Synology foundation publishes no device port. Device Gateway's
raw B2 listener is reachable only through `127.0.0.1:9921`; its health
endpoints are loopback-only. The only planned external raw-TCP termination is
the dedicated Mini Edge Relay described below.
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later
serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
as `443 -> 9921`.
The artifact never changes DSM firewall, DSM Router Configuration, DNS or a
physical router.
### Dedicated mini Device Edge
The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the
relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed
target removes even that host publication: health remains container-internal,
the relay stays on the `internal: true` private bridge for backhaul, and a
second IPvlan L2 attachment gives only the relay a LAN-routable address for
`9921/TCP`. The relay has bounded global/per-address sessions and connection
rate, a bounded source-rate table and a per-direction byte budget. It emits no
bytes of its own and does not inspect device payloads.
The admission-gate transition is deliberately fail-closed at the relay: an
ingress instance accepts only a syntactically public IPv4 source, limits its
in-memory source table to 2,048 addresses and closes either direction after
262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast,
reserved and documentation addresses are rejected before an upstream connection
is made. This is a connection-admission and resource-boundary control, not a
claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2
protocol has no TLS client identity and cellular devices do not offer a stable
source-IP allowlist, so a router/NAT mapping remains prohibited until its
separate exposure and abuse controls are reviewed.
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN
IPv4 and default route through `192.168.68.1`, while its private connected route
continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry,
host-network mode, privileged container or VPN teardown is allowed.
Enabling public ingress is a separate reviewed operation and requires all of
the following evidence:
1. A distinct, no-shell Synology SSH account and key whose sole permitted open
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
tunnel are required.
2. A private backhaul sidecar/network; the raw listener may forward only to
that tunnel. The Core token and all Core/Database secrets remain on
Synology.
3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact
cannot choose an address and never changes router, firewall or DHCP state.
A manual router/NAT rule is a later independent approval, after the relay's
admission gate and external-exposure runbook have been accepted.
4. The host full-tunnel VPN remains active. Before production activation, the
exact single-NIC IPvlan design must pass duplicate-address detection,
gateway reachability, external return-path and private-backhaul checks.
5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and
disabled command transport.
## Identity and onboarding
An IMEI is a claimed protocol identifier, not proof of tenant ownership.
- An unknown connection produces a quarantine-only discovery.
- A discovery never receives commands.
- Pilot claim requires an explicit platform-admin action.
- Production assignment requires authoritative pre-enrollment or an audited
inventory import.
- First-claim-wins by IMEI is forbidden.
The ARUSNAVI Web account login/password is used only by the human operator to
configure the additional device route. It is not a Device Plane credential.
## Protocol evidence
The official B2 material proves:
- four simultaneous monitoring server routes;
- `INTERNAL`, `EXTERNAL`, `USER_AG` and EGTS variants;
- INTERNAL server-side identification by modem IMEI;
- server route fields for DNS/IP, TCP port, protocol and optional ID;
- SMS/TCP command families and a six-digit device access password.
The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
framing contract:
- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI;
- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time;
- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains
one or more length-framed PACKET records and ends with `5D`;
- every PACKET checksum is verified before acknowledgement;
- every valid PACKAGE is acknowledged by package number;
- without acknowledgement the tracker repeats the transmission.
The pilot codec implements only that verified read/acknowledgement subset. It
does not decode telemetry tags, export command builders or accept arbitrary
server commands. An IMEI parsed from a valid HEADER2 remains a claimed
identifier and never proves tenant ownership.
## Command boundary
Outbound command transport is disabled in this baseline. No command builder is
exported.
Later lifecycle:
```text
draft -> planned -> awaiting_confirmation -> queued -> dispatched
-> acknowledged | failed | expired | unknown
```
`send` is not success. An `unknown` result forbids automatic retry.
Erase, factory reset, firmware/custom firmware, physical outputs and arbitrary
raw TCP remain forbidden until separate reviewed acceptance slices.
## Implemented local foundation
- Provider-neutral discovery, contour and opaque Foundry-binding contracts.
- B2 model profile with four parallel routes and INTERNAL/IMEI evidence.
- PostgreSQL migration for model profiles, contours, quarantine discoveries,
claimed devices, Foundry bindings and append-only audit events.
- Core health endpoint and an authenticated quarantine-ingest boundary that is
disabled unless explicitly enabled with file-backed secrets.
- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
handshake timeout, concurrent/per-source session limits and per-source
connection rate limits.
- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI
and persists only its digest, masked view and verified framing evidence.
- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command
builder or command transport is present.
- Recursive rejection of secret-like fields, raw payloads and command-shaped
input in presentation contracts.
- Automated contract, adapter, migration, Core and Gateway tests.
- Additive `component=device-plane` runner registry with exact roots, builds,
services, allowlist/denylist, runner-owned secrets, health contracts and
automatic source/runtime rollback.
- Deterministic data-only artifact builder and positive/negative regression
tests.
- Compose foundation with a private internal network, preserved PostgreSQL
volume, file-backed database password and loopback-only health publishing.
- Exact one-time PostgreSQL bootstrap descriptor, deterministic builder and
absence preflight: an existing database container or volume fails closed,
and rollback never removes the volume.
## Next activation slice
1. The Deco DHCP range has been recorded as `192.168.68.50` through
`192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that
pool and independently DAD-tested. It is pinned in Compose, descriptor,
builder and the separate Edge runner.
2. Build the deterministic `component=device-edge` artifact, promote the
root-owned Edge runner and review its `plan`. The Synology runner and inbox
are not used for this host.
3. Apply the admission-gate update only to `device-edge-relay`; prove exact
IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source
limits, internal health, private backhaul reachability, unchanged
backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback
restores the reviewed IPvlan predecessor and leaves router state unchanged.
4. Independently review and add the single router/NAT rule for TCP `9921` only,
then verify that Synology still exposes no public device port.
5. Add the NDC route to one approved B2 free server slot while preserving
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
assignment remain a later explicit platform-admin operation.
+30
View File
@@ -0,0 +1,30 @@
# Device Plane baseline test matrix
| Boundary | Required proof |
| --- | --- |
| Restricted identity | IMEI accepts exactly 15 decimal digits internally |
| Browser projection | Safe discovery view contains only a masked identifier |
| Identifier hashing | HMAC digest is deterministic and does not reveal input |
| Secret boundary | Secret-like or raw-payload keys are rejected recursively |
| Command boundary | Discovery contract rejects command-shaped input |
| Framing bound | B2 evidence inspection rejects empty and oversized buffers |
| Framing honesty | Unverified B2 bytes return `official_framing_required` |
| No identifier guessing | Embedded digit sequences are never returned as IMEI |
| Model profile | Four server routes and INTERNAL identification are recorded |
| Gelios preservation | Gelios is a parallel route, not a dependency or failover |
| Core database secret | Production Compose uses a file-backed password, not a plaintext environment value |
| Core health | Database is ready while discovery ingest and command transport remain disabled |
| Gateway health | Public ingress, TCP listener and command transport remain disabled |
| Compose exposure | Only loopback health ports `18120/18121` are published; raw `9921` is not |
| Application service scope | `files.txt` selects only affected Core/Gateway services with `--no-deps` |
| Database preservation | Ordinary application artifacts never select `device-postgres` |
| Database bootstrap | Exact descriptor selects PostgreSQL only when both container and volume are absent |
| Bootstrap rollback | Candidate container may be removed; named volume is never removed |
| Rollback predecessor | Backup records actual pre-apply services; Compose presence cannot invent Core/Gateway |
| Failed-001 evidence | Recovery requires exact failed archive, journal and backup digests |
| Partial source | Recovery accepts only DB-bootstrap source plus the observed healthy foundation runtime |
| Recovery mutation | Source is published without build, restart, recreate or service removal |
| Recovery rollback | Failed acceptance restores source only and leaves runtime unchanged |
| Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded |
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
| Runner compatibility | Existing canonical Platform registry tests remain green |
+223
View File
@@ -0,0 +1,223 @@
{
"name": "@nodedc/device-plane",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/device-plane",
"version": "0.1.0",
"workspaces": [
"packages/*",
"services/*"
],
"engines": {
"node": ">=20"
}
},
"node_modules/@nodedc/arusnavi-b2-adapter": {
"resolved": "packages/arusnavi-b2-adapter",
"link": true
},
"node_modules/@nodedc/device-control-core": {
"resolved": "services/device-control-core",
"link": true
},
"node_modules/@nodedc/device-edge-relay": {
"resolved": "services/device-edge-relay",
"link": true
},
"node_modules/@nodedc/device-gateway": {
"resolved": "services/device-gateway",
"link": true
},
"node_modules/@nodedc/device-protocol-contract": {
"resolved": "packages/device-protocol-contract",
"link": true
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"packages/arusnavi-b2-adapter": {
"name": "@nodedc/arusnavi-b2-adapter",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"packages/device-protocol-contract": {
"name": "@nodedc/device-protocol-contract",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"services/device-control-core": {
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"dependencies": {
"pg": "^8.18.0"
},
"engines": {
"node": ">=20"
}
},
"services/device-edge-relay": {
"name": "@nodedc/device-edge-relay",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"services/device-gateway": {
"name": "@nodedc/device-gateway",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@nodedc/device-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*",
"services/*"
],
"scripts": {
"test": "node --test packages/*/test/*.test.mjs services/*/test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,15 @@
{
"name": "@nodedc/arusnavi-b2-adapter",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,262 @@
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
"arusnavi.internal.protocol-sheet.gid-12.v1";
const HEADER2_LENGTH = 10;
const HEADER_START = 0xff;
const HEADER2_GPRS_VERSION = 0x23;
const PACKAGE_START = 0x5b;
const PACKAGE_END = 0x5d;
const SERVER_COMMAND_START = 0x7b;
const SERVER_COMMAND_END = 0x7d;
const MIN_PACKAGE_NUMBER = 0x01;
const MAX_PACKAGE_NUMBER = 0xfb;
const PACKET_FIXED_LENGTH = 8;
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
const MAX_PACKAGE_LENGTH = 64 * 1024;
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
schemaVersion: "nodedc.device-model-profile.v1",
profileRef: "arusnavi.b2.internal.v1",
vendor: "ARUSNAVI",
model: "B2",
deviceType: "tracker",
protocol: "INTERNAL",
monitoringServerSlots: 4,
serverIdentity: {
kind: "imei",
source: "modem",
trust: "claimed-not-ownership-proof",
},
bootstrap: {
operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR",
platformCredentialRequired: false,
preserveExistingRoutes: true,
},
framing: {
status: "verified-read-only",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
headerVersion: "HEADER2_GPRS_0x23",
headerBytes: HEADER2_LENGTH,
maxBufferedBytes: MAX_PACKAGE_LENGTH,
},
acknowledgement: {
header: "server-time-only",
package: "package-number-only",
},
commandTransport: {
status: "disabled",
exportedCommandBuilders: 0,
},
routeCompatibility: {
gelios: "parallel-preserved",
automaticCommandFailover: false,
},
});
export function tryParseB2Header2(input) {
assertBuffer(input, "b2_header_buffer_required");
if (input.length < HEADER2_LENGTH) {
return Object.freeze({
status: "incomplete",
minimumBytes: HEADER2_LENGTH,
});
}
if (input[0] !== HEADER_START) {
throw new TypeError("b2_header_start_invalid");
}
if (input[1] !== HEADER2_GPRS_VERSION) {
throw new TypeError("b2_header_version_unsupported");
}
const identifier = input.readBigUInt64LE(2).toString(10);
if (!/^\d{15}$/.test(identifier)) {
throw new TypeError("b2_header_imei_invalid");
}
return Object.freeze({
status: "complete",
bytesConsumed: HEADER2_LENGTH,
identifier: Object.freeze({
kind: "imei",
value: identifier,
trust: "claimed-not-ownership-proof",
}),
evidence: Object.freeze({
transport: "tcp",
bytesObserved: HEADER2_LENGTH,
framingStatus: "verified",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
}),
});
}
export function buildB2HeaderAcknowledgement(unixSeconds) {
const timestamp = normalizeUInt32(
unixSeconds,
"b2_header_ack_timestamp_invalid",
);
const commandData = Buffer.allocUnsafe(4);
commandData.writeUInt32LE(timestamp);
return Buffer.from([
SERVER_COMMAND_START,
commandData.length,
0x00,
checksum(commandData),
...commandData,
SERVER_COMMAND_END,
]);
}
export function tryParseB2Package(input) {
assertBuffer(input, "b2_package_buffer_required");
if (input.length === 0) {
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
}
if (input[0] !== PACKAGE_START) {
throw new TypeError("b2_package_start_invalid");
}
if (input.length < 3) {
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
}
const packageNumber = input[1];
if (
packageNumber < MIN_PACKAGE_NUMBER
|| packageNumber > MAX_PACKAGE_NUMBER
) {
throw new TypeError("b2_package_number_invalid");
}
let offset = 2;
let packetCount = 0;
while (true) {
if (offset >= MAX_PACKAGE_LENGTH) {
throw new TypeError("b2_package_length_exceeded");
}
if (offset >= input.length) {
return Object.freeze({
status: "incomplete",
minimumBytes: offset + 1,
});
}
if (input[offset] === PACKAGE_END) {
if (packetCount === 0) {
throw new TypeError("b2_package_empty");
}
return Object.freeze({
status: "complete",
bytesConsumed: offset + 1,
packageNumber,
packetCount,
});
}
if (input.length - offset < 3) {
return Object.freeze({
status: "incomplete",
minimumBytes: offset + 3,
});
}
const dataLength = input.readUInt16LE(offset + 1);
if (dataLength > MAX_PACKET_DATA_LENGTH) {
throw new TypeError("b2_packet_data_length_exceeded");
}
const packetLength = PACKET_FIXED_LENGTH + dataLength;
const packetEnd = offset + packetLength;
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
throw new TypeError("b2_package_length_exceeded");
}
if (input.length < packetEnd) {
return Object.freeze({
status: "incomplete",
minimumBytes: packetEnd,
});
}
const expectedChecksum = checksum(
input.subarray(offset + 3, packetEnd - 1),
);
if (input[packetEnd - 1] !== expectedChecksum) {
throw new TypeError("b2_packet_checksum_invalid");
}
packetCount += 1;
offset = packetEnd;
}
}
export function buildB2PackageAcknowledgement(packageNumber) {
const normalized = Number(packageNumber);
if (
!Number.isSafeInteger(normalized)
|| normalized < MIN_PACKAGE_NUMBER
|| normalized > MAX_PACKAGE_NUMBER
) {
throw new TypeError("b2_package_ack_number_invalid");
}
return Buffer.from([
SERVER_COMMAND_START,
0x00,
normalized,
SERVER_COMMAND_END,
]);
}
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
if (profile.monitoringServerSlots !== 4) {
throw new TypeError("b2_server_slot_count_invalid");
}
if (profile.protocol !== "INTERNAL") {
throw new TypeError("b2_protocol_invalid");
}
if (profile.serverIdentity.kind !== "imei") {
throw new TypeError("b2_identity_kind_invalid");
}
if (profile.framing.status !== "verified-read-only") {
throw new TypeError("b2_framing_must_be_verified");
}
if (
profile.framing.specificationRef
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
) {
throw new TypeError("b2_framing_specification_invalid");
}
if (profile.commandTransport.status !== "disabled") {
throw new TypeError("b2_command_transport_must_be_disabled");
}
if (profile.routeCompatibility.gelios !== "parallel-preserved") {
throw new TypeError("b2_gelios_route_must_be_preserved");
}
return true;
}
function checksum(input) {
let value = 0;
for (const byte of input) value = (value + byte) & 0xff;
return value;
}
function normalizeUInt32(value, errorCode) {
const normalized = Number(value);
if (
!Number.isSafeInteger(normalized)
|| normalized < 0
|| normalized > 0xffffffff
) {
throw new TypeError(errorCode);
}
return normalized;
}
function assertBuffer(input, errorCode) {
if (!Buffer.isBuffer(input)) {
throw new TypeError(errorCode);
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ARUSNAVI_B2_MODEL_PROFILE,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
assertB2ProfileInvariant,
buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../src/index.mjs";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("records the official B2 route, framing and identity evidence", () => {
assert.equal(assertB2ProfileInvariant(), true);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
);
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
"parallel-preserved",
);
});
test("parses the official HEADER2 example as a claimed IMEI", () => {
assert.equal(
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
"incomplete",
);
const parsed = tryParseB2Header2(specificationHeader);
assert.equal(parsed.status, "complete");
assert.equal(parsed.bytesConsumed, 10);
assert.equal(parsed.identifier.kind, "imei");
assert.equal(parsed.identifier.value, "865209039777769");
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
assert.deepEqual(parsed.evidence, {
transport: "tcp",
bytesObserved: 10,
framingStatus: "verified",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
});
});
test("builds the official HEADER2 acknowledgement example", () => {
assert.equal(
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
"7B0400A0DE95DB527D",
);
});
test("parses and acknowledges the official package example", () => {
assert.equal(
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
"incomplete",
);
assert.deepEqual(tryParseB2Package(specificationPackage), {
status: "complete",
bytesConsumed: specificationPackage.length,
packageNumber: 1,
packetCount: 1,
});
assert.equal(
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
"7B00017D",
);
});
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
const packetData = Buffer.from([0x5d]);
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
const checksum = (0x01 + 0x5d) & 0xff;
const packageBytes = Buffer.from([
0x5b,
0x02,
0x01,
packetData.length,
0x00,
...unixTime,
...packetData,
checksum,
0x5d,
]);
assert.deepEqual(tryParseB2Package(packageBytes), {
status: "complete",
bytesConsumed: packageBytes.length,
packageNumber: 2,
packetCount: 1,
});
});
test("fails closed on unsupported headers and malformed packages", () => {
assert.throws(
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
/b2_header_start_invalid/,
);
assert.throws(
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
/b2_header_version_unsupported/,
);
const badChecksum = Buffer.from(specificationPackage);
badChecksum[badChecksum.length - 2] ^= 0xff;
assert.throws(
() => tryParseB2Package(badChecksum),
/b2_packet_checksum_invalid/,
);
});
test("exports no command builder and keeps transport disabled", () => {
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status, "disabled");
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders,
0,
);
});
@@ -0,0 +1,15 @@
{
"name": "@nodedc/device-protocol-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,239 @@
import { createHmac } from "node:crypto";
export const DEVICE_DISCOVERY_SIGNAL_SCHEMA =
"nodedc.device.discovery-signal.v1";
export const DEVICE_DISCOVERY_VIEW_SCHEMA =
"nodedc.device.discovery-view.v1";
export const DEVICE_PLANE_BINDING_SCHEMA =
"nodedc.device-plane-control.binding.v1";
export const DEVICE_LIFECYCLE_STATES = Object.freeze([
"quarantine",
"claimed",
"online",
"offline",
"retired",
]);
export const DEVICE_BINDING_CAPABILITIES = Object.freeze([
"observe",
"inspect",
"configure",
"command",
]);
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const IMEI_RE = /^\d{15}$/;
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
const forbiddenKeyFragments = Object.freeze([
"password",
"secret",
"credential",
"rawpayload",
"rawpacket",
"command",
"authorization",
"token",
]);
const safeStatusKeys = new Set([
"commandtransport",
]);
export function normalizeDiscoverySignal(input) {
assertPlainObject(input, "discovery_signal");
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) {
throw new TypeError("discovery_signal_schema_invalid");
}
const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref");
const modelProfileRef = normalizeOpaqueRef(
input.modelProfileRef,
"model_profile_ref",
);
const protocol = normalizeUpperToken(input.protocol, "protocol");
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
const identifier = normalizeRestrictedIdentifier(input.identifier);
const evidence = normalizeDiscoveryEvidence(input.evidence);
return Object.freeze({
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef,
modelProfileRef,
protocol,
observedAt,
identifier,
evidence,
lifecycleState: "quarantine",
commandTransport: "disabled",
});
}
export function toSafeDiscoveryView(signal, options = {}) {
const normalized = normalizeDiscoverySignal(signal);
const discoveryRef = options.discoveryRef
? normalizeOpaqueRef(options.discoveryRef, "discovery_ref")
: undefined;
return Object.freeze({
schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA,
...(discoveryRef ? { discoveryRef } : {}),
modelProfileRef: normalized.modelProfileRef,
protocol: normalized.protocol,
observedAt: normalized.observedAt,
lifecycleState: normalized.lifecycleState,
identifier: Object.freeze({
kind: normalized.identifier.kind,
masked: maskRestrictedIdentifier(normalized.identifier),
}),
evidence: normalized.evidence,
commandTransport: "disabled",
});
}
export function hashRestrictedIdentifier(identifier, pepper) {
const normalized = normalizeRestrictedIdentifier(identifier);
if (typeof pepper !== "string" || pepper.length < 32) {
throw new TypeError("identifier_pepper_invalid");
}
const digest = createHmac("sha256", pepper)
.update(`${normalized.kind}\0${normalized.value}`, "utf8")
.digest("hex");
return `hmac-sha256:${digest}`;
}
export function assertIdentifierDigest(value) {
if (typeof value !== "string" || !DIGEST_RE.test(value)) {
throw new TypeError("identifier_digest_invalid");
}
return value;
}
export function normalizeDevicePlaneBinding(input) {
assertPlainObject(input, "device_plane_binding");
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) {
throw new TypeError("device_plane_binding_schema_invalid");
}
const allowed = new Set(DEVICE_BINDING_CAPABILITIES);
if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) {
throw new TypeError("device_plane_binding_capabilities_invalid");
}
const capabilities = [...new Set(input.capabilities.map((value) => {
if (typeof value !== "string" || !allowed.has(value)) {
throw new TypeError("device_plane_binding_capability_invalid");
}
return value;
}))].sort();
return Object.freeze({
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"),
contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"),
capabilities: Object.freeze(capabilities),
});
}
export function assertSafeProjection(value) {
assertPlainObject(value, "safe_projection");
rejectForbiddenKeys(value);
const serialized = JSON.stringify(value);
if (/\b\d{15}\b/.test(serialized)) {
throw new TypeError("safe_projection_contains_unmasked_imei");
}
return value;
}
function normalizeRestrictedIdentifier(input) {
assertPlainObject(input, "restricted_identifier");
if (input.kind !== "imei") {
throw new TypeError("restricted_identifier_kind_unsupported");
}
if (typeof input.value !== "string" || !IMEI_RE.test(input.value)) {
throw new TypeError("restricted_identifier_imei_invalid");
}
return Object.freeze({ kind: "imei", value: input.value });
}
function maskRestrictedIdentifier(identifier) {
if (identifier.kind === "imei") {
return `***********${identifier.value.slice(-4)}`;
}
throw new TypeError("restricted_identifier_kind_unsupported");
}
function normalizeDiscoveryEvidence(input) {
assertPlainObject(input, "discovery_evidence");
rejectForbiddenKeys(input);
if (input.transport !== "tcp") {
throw new TypeError("discovery_evidence_transport_invalid");
}
const bytesObserved = Number(input.bytesObserved);
if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) {
throw new TypeError("discovery_evidence_bytes_invalid");
}
if (input.framingStatus !== "verified") {
throw new TypeError("discovery_evidence_framing_unverified");
}
return Object.freeze({
transport: "tcp",
bytesObserved,
framingStatus: "verified",
specificationRef: normalizeOpaqueRef(
input.specificationRef,
"framing_specification_ref",
),
});
}
function rejectForbiddenKeys(value, path = "$") {
if (Array.isArray(value)) {
value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
if (
!safeStatusKeys.has(normalizedKey)
&& forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment))
) {
throw new TypeError(`forbidden_device_field:${path}.${key}`);
}
rejectForbiddenKeys(child, `${path}.${key}`);
}
}
function normalizeOpaqueRef(value, label) {
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeUpperToken(value, label) {
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeTimestamp(value, label) {
if (typeof value !== "string") throw new TypeError(`${label}_invalid`);
const date = new Date(value);
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function assertPlainObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label}_invalid`);
}
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
DEVICE_PLANE_BINDING_SCHEMA,
assertIdentifierDigest,
assertSafeProjection,
hashRestrictedIdentifier,
normalizeDevicePlaneBinding,
normalizeDiscoverySignal,
toSafeDiscoveryView,
} from "../src/index.mjs";
const fakeImei = "000000000000001";
const fakeSignal = {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: "session:test-001",
modelProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
observedAt: "2026-07-25T00:00:00.000Z",
identifier: {
kind: "imei",
value: fakeImei,
},
evidence: {
transport: "tcp",
bytesObserved: 128,
framingStatus: "verified",
specificationRef: "arusnavi.internal.framing.test-v1",
},
};
test("normalizes a verified discovery into quarantine with commands disabled", () => {
const signal = normalizeDiscoverySignal(fakeSignal);
assert.equal(signal.lifecycleState, "quarantine");
assert.equal(signal.commandTransport, "disabled");
assert.equal(signal.identifier.value, fakeImei);
});
test("safe discovery projection masks the restricted identifier", () => {
const view = toSafeDiscoveryView(fakeSignal, {
discoveryRef: "discovery:test-001",
});
const serialized = JSON.stringify(view);
assert.equal(view.identifier.masked, "***********0001");
assert.equal(serialized.includes(fakeImei), false);
assertSafeProjection(view);
});
test("identifier hashing requires a strong process-only pepper", () => {
const identifier = { kind: "imei", value: fakeImei };
assert.throws(
() => hashRestrictedIdentifier(identifier, "short"),
/identifier_pepper_invalid/,
);
const digest = hashRestrictedIdentifier(
identifier,
"test-only-pepper-with-at-least-32-bytes",
);
assertIdentifierDigest(digest);
assert.equal(digest.includes(fakeImei), false);
assert.equal(
digest,
hashRestrictedIdentifier(
identifier,
"test-only-pepper-with-at-least-32-bytes",
),
);
});
test("rejects unverified framing and command-shaped discovery input", () => {
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
evidence: { ...fakeSignal.evidence, framingStatus: "unverified" },
}),
/discovery_evidence_framing_unverified/,
);
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
command: { kind: "restart" },
}),
/forbidden_device_field/,
);
});
test("rejects secret-like fields recursively", () => {
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
metadata: { devicePassword: "not-a-real-password" },
}),
/forbidden_device_field/,
);
});
test("normalizes an opaque Foundry control binding without device data", () => {
const binding = normalizeDevicePlaneBinding({
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
bindingRef: "binding:test-001",
contourRef: "contour:robot2b-test",
capabilities: ["inspect", "observe", "observe"],
});
assert.deepEqual(binding.capabilities, ["inspect", "observe"]);
assertSafeProjection(binding);
});
@@ -0,0 +1,12 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-server \
&& addgroup -g 11021 device-backhaul \
&& adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \
&& passwd -u device-backhaul
COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config
EXPOSE 2222/tcp
CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"]
@@ -0,0 +1,35 @@
Port 2222
ListenAddress 127.0.0.1
AddressFamily inet
Protocol 2
HostKey /run/nodedc-secrets/ssh_host_ed25519_key
AuthorizedKeysFile /run/nodedc-secrets/authorized_keys
StrictModes no
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers device-backhaul
AllowTcpForwarding local
PermitOpen 127.0.0.1:9921
GatewayPorts no
PermitTunnel no
AllowAgentForwarding no
X11Forwarding no
PermitTTY no
PermitUserEnvironment no
ForceCommand /bin/false
ClientAliveInterval 30
ClientAliveCountMax 3
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 4
MaxStartups 4:30:8
UseDNS no
PrintMotd no
LogLevel VERBOSE
@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-control-core ./services/device-control-core
COPY services/device-gateway/package.json ./services/device-gateway/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts
USER node
CMD ["node", "services/device-control-core/src/server.mjs"]
@@ -0,0 +1,99 @@
begin;
create table if not exists device_model_profiles (
profile_ref text primary key,
schema_version text not null,
vendor text not null,
model text not null,
device_type text not null,
protocol text not null,
profile jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists device_contours (
id uuid primary key,
owner_scope text not null,
name text not null,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'suspended', 'retired')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (owner_scope, name)
);
create table if not exists device_discoveries (
id uuid primary key,
identifier_kind text not null,
identifier_digest text not null,
identifier_masked text not null,
model_profile_ref text not null references device_model_profiles(profile_ref),
protocol text not null,
lifecycle_state text not null default 'quarantine'
check (lifecycle_state in ('quarantine', 'claimed', 'rejected', 'expired')),
first_observed_at timestamptz not null,
last_observed_at timestamptz not null,
evidence jsonb not null,
claimed_device_id uuid,
claimed_at timestamptz,
claimed_by text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (identifier_kind, identifier_digest, model_profile_ref)
);
create index if not exists device_discoveries_state_last_seen_idx
on device_discoveries (lifecycle_state, last_observed_at desc);
create table if not exists device_instances (
id uuid primary key,
contour_id uuid not null references device_contours(id),
model_profile_ref text not null references device_model_profiles(profile_ref),
display_name text not null,
identifier_kind text not null,
identifier_digest text not null,
identifier_masked text not null,
credential_ref text,
lifecycle_state text not null default 'claimed'
check (lifecycle_state in ('claimed', 'online', 'offline', 'suspended', 'retired')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (identifier_kind, identifier_digest, model_profile_ref)
);
alter table device_discoveries
drop constraint if exists device_discoveries_claimed_device_fk;
alter table device_discoveries
add constraint device_discoveries_claimed_device_fk
foreign key (claimed_device_id) references device_instances(id);
create table if not exists device_bindings (
id uuid primary key,
contour_id uuid not null references device_contours(id),
target_kind text not null,
target_ref text not null,
capabilities text[] not null,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'revoked')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (contour_id, target_kind, target_ref)
);
create table if not exists device_audit_events (
id uuid primary key,
event_type text not null,
actor_ref text not null,
contour_id uuid,
device_id uuid,
discovery_id uuid,
payload jsonb not null,
occurred_at timestamptz not null default now()
);
create index if not exists device_audit_events_device_time_idx
on device_audit_events (device_id, occurred_at desc);
commit;
@@ -0,0 +1,16 @@
{
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"pg": "^8.18.0"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,152 @@
import { timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import {
assertSafeProjection,
hashRestrictedIdentifier,
normalizeDiscoverySignal,
toSafeDiscoveryView,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createControlCoreApp({
repository,
gatewayToken = "",
identifierPepper = "",
discoveryIngestEnabled = false,
} = {}) {
if (!repository || typeof repository.health !== "function") {
throw new TypeError("device_repository_required");
}
if (discoveryIngestEnabled) {
if (typeof repository.upsertQuarantineDiscovery !== "function") {
throw new TypeError("device_discovery_repository_required");
}
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
throw new TypeError("device_gateway_token_invalid");
}
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
throw new TypeError("device_identifier_pepper_invalid");
}
}
const server = createServer(async (request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
try {
const requestUrl = new URL(
request.url || "/",
`http://${request.headers.host || "127.0.0.1"}`,
);
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
const database = await repository.health();
return writeJson(response, 200, {
ok: true,
service: "nodedc-device-control-core",
database,
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
commandTransport: "disabled",
});
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
) {
if (!discoveryIngestEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_discovery_ingest_disabled",
});
}
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_gateway_auth_required",
});
}
const input = await readJsonBody(request, 32 * 1024);
const signal = normalizeDiscoverySignal(input);
const identifierDigest = hashRestrictedIdentifier(
signal.identifier,
identifierPepper,
);
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
const discovery = await repository.upsertQuarantineDiscovery({
identifierDigest,
safeView,
});
return writeJson(response, discovery.created ? 201 : 200, {
ok: true,
created: discovery.created,
discovery: assertSafeProjection(discovery.value),
});
}
return writeJson(response, 404, {
ok: false,
error: "device_control_core_route_not_found",
});
} catch (error) {
const status = Number(error?.statusCode || 400);
return writeJson(
response,
Number.isInteger(status) && status >= 400 && status < 600
? status
: 500,
{
ok: false,
error: safeErrorCode(error),
},
);
}
});
return server;
}
function matchesBearer(header, expected) {
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
const required = Buffer.from(expected, "utf8");
return (
actual.length === required.length
&& required.length > 0
&& timingSafeEqual(actual, required)
);
}
async function readJsonBody(request, maxBytes) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) {
const error = new Error("device_request_body_too_large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
if (size === 0) throw new TypeError("device_request_body_required");
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new TypeError("device_request_json_invalid");
}
}
function writeJson(response, status, body) {
response.statusCode = status;
return response.end(`${JSON.stringify(body)}\n`);
}
function safeErrorCode(error) {
const value = error instanceof Error ? error.message : "device_control_error";
return /^[a-z0-9_:-]{1,128}$/.test(value)
? value
: "device_control_error";
}
@@ -0,0 +1,73 @@
import { readFile } from "node:fs/promises";
export async function resolveDeviceDatabaseUrl(
environment = process.env,
readSecret = readFile,
) {
const explicit = optionalValue(environment.DEVICE_DATABASE_URL);
if (explicit) return explicit;
const host = restrictedValue(
environment.DEVICE_DATABASE_HOST,
/^[A-Za-z0-9.-]{1,253}$/,
"device_database_host_invalid",
);
const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432);
const database = restrictedValue(
environment.DEVICE_DATABASE_NAME,
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
"device_database_name_invalid",
);
const user = restrictedValue(
environment.DEVICE_DATABASE_USER,
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
"device_database_user_invalid",
);
const passwordFile = requiredValue(
environment.DEVICE_DATABASE_PASSWORD_FILE,
"device_database_password_file_required",
);
const password = (await readSecret(passwordFile, "utf8")).trim();
if (password.length < 32 || password.length > 512) {
throw new Error("device_database_password_invalid");
}
return [
"postgresql://",
encodeURIComponent(user),
":",
encodeURIComponent(password),
"@",
host,
":",
String(port),
"/",
encodeURIComponent(database),
"?sslmode=disable",
].join("");
}
function optionalValue(value) {
if (typeof value !== "string") return "";
return value.trim();
}
function requiredValue(value, errorCode) {
const normalized = optionalValue(value);
if (!normalized) throw new Error(errorCode);
return normalized;
}
function restrictedValue(value, pattern, errorCode) {
const normalized = requiredValue(value, errorCode);
if (!pattern.test(normalized)) throw new Error(errorCode);
return normalized;
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_database_port_invalid");
}
return parsed;
}
@@ -0,0 +1,128 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
const { Pool } = pg;
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export class PostgresDeviceRepository {
constructor({ databaseUrl, poolSize = 10 } = {}) {
if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") {
throw new TypeError("device_database_url_required");
}
this.pool = new Pool({
connectionString: databaseUrl,
max: normalizePoolSize(poolSize),
});
}
async migrate() {
const sql = await readFile(
resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"),
"utf8",
);
await this.pool.query(sql);
await this.pool.query(
`insert into device_model_profiles (
profile_ref,
schema_version,
vendor,
model,
device_type,
protocol,
profile
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)
on conflict (profile_ref) do update set
schema_version = excluded.schema_version,
profile = excluded.profile,
updated_at = now()`,
[
ARUSNAVI_B2_MODEL_PROFILE.profileRef,
ARUSNAVI_B2_MODEL_PROFILE.schemaVersion,
ARUSNAVI_B2_MODEL_PROFILE.vendor,
ARUSNAVI_B2_MODEL_PROFILE.model,
ARUSNAVI_B2_MODEL_PROFILE.deviceType,
ARUSNAVI_B2_MODEL_PROFILE.protocol,
JSON.stringify(ARUSNAVI_B2_MODEL_PROFILE),
],
);
}
async health() {
await this.pool.query("select 1");
return "ready";
}
async upsertQuarantineDiscovery({ identifierDigest, safeView }) {
const result = await this.pool.query(
`insert into device_discoveries (
id,
identifier_kind,
identifier_digest,
identifier_masked,
model_profile_ref,
protocol,
lifecycle_state,
first_observed_at,
last_observed_at,
evidence
) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb)
on conflict (identifier_kind, identifier_digest, model_profile_ref)
do update set
last_observed_at = greatest(
device_discoveries.last_observed_at,
excluded.last_observed_at
),
evidence = excluded.evidence,
updated_at = now()
returning id, lifecycle_state, model_profile_ref, protocol,
identifier_kind, identifier_masked, first_observed_at,
last_observed_at, (xmax = 0) as created`,
[
randomUUID(),
safeView.identifier.kind,
identifierDigest,
safeView.identifier.masked,
safeView.modelProfileRef,
safeView.protocol,
safeView.observedAt,
JSON.stringify(safeView.evidence),
],
);
const row = result.rows[0];
return {
created: row.created === true,
value: {
schemaVersion: "nodedc.device.discovery-view.v1",
discoveryRef: `discovery:${row.id}`,
modelProfileRef: row.model_profile_ref,
protocol: row.protocol,
observedAt: new Date(row.last_observed_at).toISOString(),
lifecycleState: row.lifecycle_state,
identifier: {
kind: row.identifier_kind,
masked: row.identifier_masked,
},
evidence: safeView.evidence,
commandTransport: "disabled",
},
};
}
async close() {
await this.pool.end();
}
}
function normalizePoolSize(value) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
throw new TypeError("device_database_pool_size_invalid");
}
return parsed;
}
@@ -0,0 +1,107 @@
import { readFile } from "node:fs/promises";
import { createControlCoreApp } from "./app.mjs";
import { resolveDeviceDatabaseUrl } from "./database-config.mjs";
import { PostgresDeviceRepository } from "./postgres-repository.mjs";
const config = await readConfig();
const repository = new PostgresDeviceRepository({
databaseUrl: config.databaseUrl,
poolSize: config.databasePoolSize,
});
await repository.migrate();
const server = createControlCoreApp({
repository,
gatewayToken: config.gatewayToken,
identifierPepper: config.identifierPepper,
discoveryIngestEnabled: config.discoveryIngestEnabled,
});
server.listen(config.port, config.host, () => {
console.log(JSON.stringify({
event: "device_control_core_started",
host: config.host,
port: config.port,
discoveryIngest: config.discoveryIngestEnabled,
commandTransport: "disabled",
}));
});
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
server.close(async () => {
await repository.close();
process.exit(0);
});
}
async function readConfig() {
const discoveryIngestEnabled = parseBoolean(
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
false,
);
return {
host: String(process.env.HOST || "127.0.0.1").trim(),
port: parsePort(process.env.PORT, 18120),
databaseUrl: await resolveDeviceDatabaseUrl(process.env),
databasePoolSize: parsePositiveInt(
process.env.DEVICE_DATABASE_POOL_SIZE,
10,
),
discoveryIngestEnabled,
gatewayToken: discoveryIngestEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
"device_gateway_core_token_file_required",
)
: "",
identifierPepper: discoveryIngestEnabled
? await readRequiredSecretFile(
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
"device_identifier_pepper_file_required",
)
: "",
};
}
async function readRequiredSecretFile(path, errorCode) {
const normalized = requiredValue(path, errorCode);
const value = (await readFile(normalized, "utf8")).trim();
if (value.length < 32) throw new Error(errorCode);
return value;
}
function requiredValue(value, errorCode) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(errorCode);
}
return value.trim();
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_control_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_boolean_invalid");
}
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import { createControlCoreApp } from "../src/app.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const fakeImei = "000000000000001";
test("health reports database readiness and disabled command transport", async () => {
const runtime = await startTestServer({
repository: {
health: async () => "ready",
},
});
try {
const response = await fetch(`${runtime.baseUrl}/healthz`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
ok: true,
service: "nodedc-device-control-core",
database: "ready",
discoveryIngest: "disabled",
commandTransport: "disabled",
});
} finally {
await runtime.close();
}
});
test("discovery ingest is closed by default", async () => {
const runtime = await startTestServer({
repository: {
health: async () => "ready",
},
});
try {
const response = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
assert.equal(response.status, 404);
assert.equal(
(await response.json()).error,
"device_discovery_ingest_disabled",
);
} finally {
await runtime.close();
}
});
test("authenticated ingest stores only digest and returns a masked view", async () => {
let stored;
const runtime = await startTestServer({
discoveryIngestEnabled: true,
gatewayToken,
identifierPepper,
repository: {
health: async () => "ready",
upsertQuarantineDiscovery: async (value) => {
stored = value;
return {
created: true,
value: {
...value.safeView,
discoveryRef: "discovery:test-001",
},
};
},
},
});
try {
const unauthorized = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fakeSignal()),
},
);
assert.equal(unauthorized.status, 401);
const response = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(fakeSignal()),
},
);
assert.equal(response.status, 201);
const body = await response.json();
const serialized = JSON.stringify(body);
assert.equal(serialized.includes(fakeImei), false);
assert.equal(body.discovery.identifier.masked, "***********0001");
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
} finally {
await runtime.close();
}
});
function fakeSignal() {
return {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: "session:test-001",
modelProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
observedAt: "2026-07-25T00:00:00.000Z",
identifier: { kind: "imei", value: fakeImei },
evidence: {
transport: "tcp",
bytesObserved: 128,
framingStatus: "verified",
specificationRef: "arusnavi.internal.framing.test-v1",
},
};
}
async function startTestServer(options) {
const server = createControlCoreApp(options);
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDeviceDatabaseUrl } from "../src/database-config.mjs";
test("builds the database URL from a file-backed password", async () => {
const password = "test-only-database-password-with-32-bytes";
const url = await resolveDeviceDatabaseUrl(
{
DEVICE_DATABASE_HOST: "device-postgres",
DEVICE_DATABASE_PORT: "5432",
DEVICE_DATABASE_NAME: "device_plane",
DEVICE_DATABASE_USER: "device_plane",
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
},
async (path, encoding) => {
assert.equal(path, "/run/test/postgres-password");
assert.equal(encoding, "utf8");
return `${password}\n`;
},
);
assert.equal(
url,
`postgresql://device_plane:${encodeURIComponent(password)}@device-postgres:5432/device_plane?sslmode=disable`,
);
});
test("rejects a short file-backed database password", async () => {
await assert.rejects(
resolveDeviceDatabaseUrl(
{
DEVICE_DATABASE_HOST: "device-postgres",
DEVICE_DATABASE_NAME: "device_plane",
DEVICE_DATABASE_USER: "device_plane",
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
},
async () => "too-short",
),
/device_database_password_invalid/,
);
});
test("keeps an explicit database URL as a compatibility-only boundary", async () => {
const explicit = "postgresql://local:test@127.0.0.1:5432/device_plane";
assert.equal(
await resolveDeviceDatabaseUrl({ DEVICE_DATABASE_URL: explicit }),
explicit,
);
});
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/001_device_plane_foundation.sql",
import.meta.url,
);
test("foundation migration keeps restricted identifiers hashed and DB private", async () => {
const sql = await readFile(migrationUrl, "utf8");
assert.match(sql, /identifier_digest text not null/);
assert.match(sql, /identifier_masked text not null/);
assert.doesNotMatch(sql, /imei\s+text/i);
assert.doesNotMatch(sql, /password\s+text/i);
assert.doesNotMatch(sql, /raw_packet/i);
});
test("foundation migration has quarantine, contour, binding and audit tables", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_model_profiles",
"device_contours",
"device_discoveries",
"device_instances",
"device_bindings",
"device_audit_events",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`));
}
assert.match(sql, /default 'quarantine'/);
});
@@ -0,0 +1,9 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-client-default netcat-openbsd \
&& addgroup -g 1000 edge-backhaul \
&& adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul
USER 1000:1000
ENTRYPOINT ["/usr/bin/ssh"]
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const devicePlane = resolve(here, "../../..");
test("edge backhaul is key-only, pinned and never host-published", async () => {
const compose = await readFile(
resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"),
"utf8",
));
assert.match(compose, /StrictHostKeyChecking=yes/);
assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/);
assert.match(compose, /PasswordAuthentication=no/);
assert.match(compose, /KbdInteractiveAuthentication=no/);
assert.match(compose, /ExitOnForwardFailure=yes/);
assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/);
assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/);
assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/);
assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/);
assert.doesNotMatch(compose, /^\s+ports:/m);
assert.doesNotMatch(compose, /0\.0\.0\.0:9921/);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.deviceIngress, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
});
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY services/device-edge-relay/src ./src
USER node
CMD ["node", "src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-edge-relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,384 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer } from "node:net";
export function createDeviceEdgeRelayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalForwarded = 0;
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
const remoteAddress = config.resolveRemoteAddress(socket.remoteAddress);
if (
!allowsSource(remoteAddress)
|| sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const session = {
remoteAddress,
socket,
upstream: null,
closed: false,
forwarded: false,
inboundBytes: 0,
outboundBytes: 0,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.pause();
socket.on("timeout", () => rejectSession(session));
socket.on("close", () => closeSession(session));
socket.on("error", () => rejectSession(session));
socket.on("data", (chunk) => {
session.inboundBytes += chunk.length;
if (session.inboundBytes > config.maxBytesPerDirection) {
rejectSession(session);
}
});
const upstream = connect({
host: config.upstreamHost,
port: config.upstreamPort,
});
session.upstream = upstream;
upstream.setNoDelay(true);
upstream.setTimeout(config.sessionTimeoutMs);
upstream.on("connect", () => {
if (session.closed) {
upstream.destroy();
return;
}
session.forwarded = true;
totalForwarded += 1;
socket.pipe(upstream);
upstream.pipe(socket);
socket.resume();
});
upstream.on("timeout", () => rejectSession(session));
upstream.on("error", () => rejectSession(session));
upstream.on("close", () => closeSession(session));
upstream.on("data", (chunk) => {
session.outboundBytes += chunk.length;
if (session.outboundBytes > config.maxBytesPerDirection) {
rejectSession(session);
}
});
});
const healthServer = createHttpServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET" || request.url !== "/healthz") {
response.statusCode = 404;
response.end('{"ok":false,"error":"device_edge_relay_route_not_found"}\n');
return;
}
response.statusCode = 200;
response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-edge-relay",
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
sourceAdmission: config.sourcePolicy,
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
forwarded: totalForwarded,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.ingressEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.ingressEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const session of sessions.values()) rejectSession(session);
await Promise.all([
closeServer(healthServer),
config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalForwarded,
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
sourceAdmission: config.sourcePolicy,
};
},
};
function currentAddressSessions(remoteAddress) {
return sessionsByAddress.get(remoteAddress) || 0;
}
function incrementAddressSessions(remoteAddress) {
sessionsByAddress.set(
remoteAddress,
currentAddressSessions(remoteAddress) + 1,
);
}
function decrementAddressSessions(remoteAddress) {
const current = currentAddressSessions(remoteAddress);
if (current <= 1) {
sessionsByAddress.delete(remoteAddress);
} else {
sessionsByAddress.set(remoteAddress, current - 1);
}
}
function consumeConnectionPermit(remoteAddress) {
const nowMs = config.now().getTime();
for (const [address, window] of connectionWindows) {
if (nowMs - window.startedAt >= 60_000) {
connectionWindows.delete(address);
}
}
const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) {
if (connectionWindows.size >= config.maxTrackedSourceAddresses) {
return false;
}
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
return true;
}
if (current.count >= config.maxConnectionsPerMinutePerAddress) return false;
current.count += 1;
return true;
}
function allowsSource(remoteAddress) {
if (config.sourcePolicy === "any") return true;
return isPublicIpv4Address(remoteAddress);
}
function rejectSession(session) {
if (!session.closed) totalRejected += 1;
session.socket.destroy();
session.upstream?.destroy();
closeSession(session);
}
function closeSession(session) {
if (session.closed) return;
session.closed = true;
sessions.delete(session.socket);
decrementAddressSessions(session.remoteAddress);
}
}
function normalizeConfig(input) {
const ingressEnabled = input.ingressEnabled === true;
return {
ingressEnabled,
healthHost: normalizeHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18221,
0,
65535,
"device_edge_relay_health_port_invalid",
),
tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_edge_relay_tcp_port_invalid",
),
upstreamHost: ingressEnabled
? normalizeUpstreamHost(input.upstreamHost)
: "disabled",
upstreamPort: ingressEnabled
? parseInteger(
input.upstreamPort,
undefined,
1,
65535,
"device_edge_relay_upstream_port_invalid",
)
: 0,
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_edge_relay_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
10,
1,
1000,
"device_edge_relay_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
30,
1,
10000,
"device_edge_relay_connection_rate_invalid",
),
maxTrackedSourceAddresses: parseInteger(
input.maxTrackedSourceAddresses,
2048,
1,
65_536,
"device_edge_relay_source_table_limit_invalid",
),
maxBytesPerDirection: parseInteger(
input.maxBytesPerDirection,
262_144,
1_024,
16 * 1024 * 1024,
"device_edge_relay_byte_limit_invalid",
),
sourcePolicy: normalizeSourcePolicy(input.sourcePolicy, ingressEnabled),
resolveRemoteAddress: typeof input.resolveRemoteAddress === "function"
? input.resolveRemoteAddress
: normalizeRemoteAddress,
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_edge_relay_session_timeout_invalid",
),
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_edge_relay_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, ingressEnabled) {
const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1";
const normalized = String(value || fallback).trim();
const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
ingressEnabled
? "device_edge_relay_public_ingress_host_invalid"
: "device_edge_relay_baseline_loopback_only",
);
}
return normalized;
}
function normalizeUpstreamHost(value) {
const normalized = String(value || "").trim();
if (
normalized.length === 0
|| normalized.length > 253
|| /[/:\\s]/.test(normalized)
) {
throw new TypeError("device_edge_relay_upstream_host_invalid");
}
return normalized;
}
function normalizeRemoteAddress(value) {
const normalized = String(value || "unknown").trim();
return normalized.slice(0, 64) || "unknown";
}
function normalizeSourcePolicy(value, ingressEnabled) {
const fallback = ingressEnabled ? "public-ipv4-only" : "any";
const normalized = String(value || fallback).trim().toLowerCase();
if (!["any", "public-ipv4-only"].includes(normalized)) {
throw new TypeError("device_edge_relay_source_policy_invalid");
}
if (ingressEnabled && normalized !== "public-ipv4-only") {
throw new TypeError("device_edge_relay_ingress_source_policy_invalid");
}
return normalized;
}
function isPublicIpv4Address(value) {
const normalized = String(value || "").trim().replace(/^::ffff:/i, "");
const parts = normalized.split(".");
if (parts.length !== 4) return false;
const octets = parts.map((part) => Number(part));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [first, second, third] = octets;
if (
first === 0
|| first === 10
|| first === 127
|| first >= 224
|| (first === 100 && second >= 64 && second <= 127)
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 0 && third === 0)
|| (first === 192 && second === 0 && third === 2)
|| (first === 192 && second === 88 && third === 99)
|| (first === 192 && second === 168)
|| (first === 198 && (second === 18 || second === 19))
|| (first === 198 && second === 51 && third === 100)
|| (first === 203 && second === 0 && third === 113)
) {
return false;
}
return true;
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new TypeError(errorCode);
}
return parsed;
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,85 @@
import { createDeviceEdgeRelayRuntime } from "./runtime.mjs";
const runtime = createDeviceEdgeRelayRuntime({
ingressEnabled: parseBoolean(
process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED,
false,
),
healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1",
healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221),
tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST,
tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921),
upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST,
upstreamPort: parsePort(
process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT,
undefined,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS,
100,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS,
10,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30,
),
maxTrackedSourceAddresses: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES,
2048,
),
maxBytesPerDirection: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION,
262_144,
),
sourcePolicy: process.env.DEVICE_EDGE_RELAY_SOURCE_POLICY,
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
10000,
),
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_edge_relay_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
ingress: runtime.status().ingress,
protocolInspection: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
function parsePort(value, fallback) {
if (value === undefined && fallback === undefined) return undefined;
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_edge_relay_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_edge_relay_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_edge_relay_boolean_invalid");
}
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const devicePlaneRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../..",
);
test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => {
const baseline = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.yml"),
"utf8",
);
const ingress = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"),
"utf8",
);
assert.match(
baseline,
/DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/,
);
assert.doesNotMatch(baseline, /^\s+ports:/m);
assert.doesNotMatch(baseline, /device-edge-control/);
for (const required of [
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"ipvlan_mode: l2",
"ipv4_address: 192.168.71.253",
"subnet: 192.168.68.0/22",
"gateway: 192.168.68.1",
"gw_priority: 100",
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
]) {
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
}
for (const forbidden of [
"ports:",
"network_mode: host",
"privileged: true",
"DEVICE_EDGE_RELAY_COMMAND",
"0.0.0.0:9921:9921",
]) {
assert.ok(
!ingress.includes(forbidden),
`forbidden ingress boundary: ${forbidden}`,
);
}
});
test("ingress descriptor keeps address approval and router exposure gated", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-ingress-ipvlan-v1.json",
),
"utf8",
));
assert.equal(descriptor.component, "device-edge");
assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]);
assert.deepEqual(
descriptor.preservedServices,
["device-edge-backhaul", "tailnet"],
);
assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool");
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921");
assert.equal(descriptor.protocolInspection, "gateway-owned");
assert.equal(descriptor.discoveryLifecycle, "quarantine");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
test("admission-gate descriptor pins the fail-closed relay boundary", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-admission-gate-v1.json",
),
"utf8",
));
assert.equal(
descriptor.schemaVersion,
"nodedc.device-edge.admission-gate.v1",
);
assert.equal(descriptor.sourceAdmission, "public-ipv4-only");
assert.equal(descriptor.maxTrackedSourceAddresses, 2048);
assert.equal(descriptor.maxBytesPerDirection, 262144);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { createServer, connect } from "node:net";
import test from "node:test";
import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs";
test("baseline starts only loopback health and no device TCP listener", async () => {
const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 });
const addresses = await runtime.start();
try {
assert.equal(addresses.tcpAddress, null);
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.ingress, "disabled");
assert.equal(body.protocolInspection, "disabled");
assert.equal(body.commandTransport, "disabled");
} finally {
await runtime.stop();
}
});
test("relay is transparent and never emits its own protocol bytes", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
resolveRemoteAddress: () => "8.8.8.8",
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("ff230102030405060708", "hex"),
);
assert.equal(response.toString("hex"), "ff230102030405060708");
assert.equal(runtime.status().totalForwarded, 1);
assert.equal(runtime.status().commandTransport, "disabled");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("enabled ingress rejects a non-public source before opening upstream", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
resolveRemoteAddress: () => "127.0.0.1",
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("denied"),
);
assert.equal(response.length, 0);
assert.equal(runtime.status().totalAccepted, 0);
assert.equal(runtime.status().totalForwarded, 0);
assert.equal(runtime.status().sourceAdmission, "public-ipv4-only");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("relay terminates a byte stream that exceeds its per-direction budget", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
resolveRemoteAddress: () => "8.8.8.8",
maxBytesPerDirection: 1024,
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.alloc(1025, 0x5d),
);
assert.ok(response.length <= 1024);
assert.equal(runtime.status().totalForwarded, 1);
assert.ok(runtime.status().totalRejected >= 1);
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("production ingress cannot opt out of public IPv4 admission", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamHost: "device-edge-backhaul",
upstreamPort: 19921,
sourcePolicy: "any",
}),
/device_edge_relay_ingress_source_policy_invalid/,
);
});
test("enabled relay requires a concrete private upstream", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamPort: 19921,
}),
/device_edge_relay_upstream_host_invalid/,
);
});
test("baseline rejects a non-loopback device binding", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }),
/device_edge_relay_baseline_loopback_only/,
);
});
function startEchoServer() {
const server = createServer((socket) => socket.pipe(socket));
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve({ server, port: server.address().port });
});
});
}
function sendAndCollect(port, payload) {
return new Promise((resolve) => {
const chunks = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.end(payload);
});
socket.on("data", (chunk) => chunks.push(chunk));
socket.on("close", () => resolve(Buffer.concat(chunks)));
socket.on("error", () => {});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-gateway ./services/device-gateway
COPY services/device-control-core/package.json ./services/device-control-core/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts
USER node
CMD ["node", "services/device-gateway/src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,75 @@
export function createCoreDiscoveryClient({
coreUrl,
gatewayToken,
timeoutMs = 5000,
fetchImpl = fetch,
} = {}) {
const endpoint = normalizeCoreEndpoint(coreUrl);
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
throw new TypeError("device_gateway_core_token_invalid");
}
const normalizedTimeout = Number(timeoutMs);
if (
!Number.isSafeInteger(normalizedTimeout)
|| normalizedTimeout < 100
|| normalizedTimeout > 30_000
) {
throw new TypeError("device_gateway_core_timeout_invalid");
}
if (typeof fetchImpl !== "function") {
throw new TypeError("device_gateway_core_fetch_invalid");
}
return async function observeDiscovery(signal) {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(signal),
signal: AbortSignal.timeout(normalizedTimeout),
});
const body = await readBoundedJson(response, 32 * 1024);
if (!response.ok || body?.ok !== true) {
throw new Error("device_gateway_core_ingest_failed");
}
if (
!body.discovery
|| body.discovery.lifecycleState !== "quarantine"
|| body.discovery.commandTransport !== "disabled"
) {
throw new Error("device_gateway_core_ingest_contract_invalid");
}
return body.discovery;
};
}
function normalizeCoreEndpoint(value) {
let url;
try {
url = new URL(String(value || ""));
} catch {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.protocol !== "http:" || url.username || url.password) {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.pathname !== "/" || url.search || url.hash) {
throw new TypeError("device_gateway_core_url_invalid");
}
url.pathname = "/internal/v1/device-discoveries:observe";
return url.toString();
}
async function readBoundedJson(response, maxBytes) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) {
throw new Error("device_gateway_core_response_too_large");
}
try {
return JSON.parse(text);
} catch {
throw new Error("device_gateway_core_response_invalid");
}
}
@@ -0,0 +1,362 @@
import { randomUUID } from "node:crypto";
import { createServer as createHttpServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
import {
ARUSNAVI_B2_MODEL_PROFILE,
buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createDeviceGatewayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalDiscoveries = 0;
let totalPackagesAcknowledged = 0;
const tcpServer = createTcpServer((socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
if (
sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const sessionRef = `session:${randomUUID()}`;
const session = {
sessionRef,
remoteAddress,
buffer: Buffer.alloc(0),
state: "awaiting-header",
processing: false,
rejected: false,
closed: false,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.on("data", (chunk) => {
if (session.closed || session.rejected) return;
socket.pause();
session.buffer = Buffer.concat(
[session.buffer, chunk],
session.buffer.length + chunk.length,
);
if (session.buffer.length > config.maxBufferedBytes) {
rejectSession(socket, session);
return;
}
if (session.processing) return;
session.processing = true;
void processSession(socket, session)
.catch(() => rejectSession(socket, session))
.finally(() => {
session.processing = false;
if (!session.closed && !session.rejected) socket.resume();
});
});
socket.on("timeout", () => rejectSession(socket, session));
socket.on("close", () => closeSession(socket, session));
socket.on("error", () => closeSession(socket, session));
});
const healthServer = createHttpServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET" || request.url !== "/healthz") {
response.statusCode = 404;
return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n');
}
response.statusCode = 200;
return response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-gateway",
protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status,
tcpListener: config.listenEnabled ? "discovery-only" : "disabled",
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled",
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
discoveries: totalDiscoveries,
packagesAcknowledged: totalPackagesAcknowledged,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.listenEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.listenEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const [socket, session] of sessions) {
session.closed = true;
socket.destroy();
}
await Promise.all([
closeServer(healthServer),
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalDiscoveries,
totalPackagesAcknowledged,
commandTransport: "disabled",
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
};
},
};
async function processSession(socket, session) {
while (!session.closed && !session.rejected) {
if (session.state === "awaiting-header") {
const parsed = tryParseB2Header2(session.buffer);
if (parsed.status === "incomplete") return;
const observedAt = config.now().toISOString();
await config.onDiscovery?.({
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: session.sessionRef,
modelProfileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
protocol: ARUSNAVI_B2_MODEL_PROFILE.protocol,
observedAt,
identifier: {
kind: parsed.identifier.kind,
value: parsed.identifier.value,
},
evidence: parsed.evidence,
});
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
session.state = "packages";
totalDiscoveries += 1;
socket.write(buildB2HeaderAcknowledgement(
Math.floor(new Date(observedAt).getTime() / 1000),
));
continue;
}
const parsed = tryParseB2Package(session.buffer);
if (parsed.status === "incomplete") return;
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
totalPackagesAcknowledged += 1;
socket.write(buildB2PackageAcknowledgement(parsed.packageNumber));
}
}
function currentAddressSessions(remoteAddress) {
return sessionsByAddress.get(remoteAddress) || 0;
}
function incrementAddressSessions(remoteAddress) {
sessionsByAddress.set(
remoteAddress,
currentAddressSessions(remoteAddress) + 1,
);
}
function decrementAddressSessions(remoteAddress) {
const current = currentAddressSessions(remoteAddress);
if (current <= 1) {
sessionsByAddress.delete(remoteAddress);
} else {
sessionsByAddress.set(remoteAddress, current - 1);
}
}
function consumeConnectionPermit(remoteAddress) {
const nowMs = config.now().getTime();
const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) {
connectionWindows.set(remoteAddress, {
startedAt: nowMs,
count: 1,
});
return true;
}
if (current.count >= config.maxConnectionsPerMinutePerAddress) {
return false;
}
current.count += 1;
return true;
}
function rejectSession(socket, session) {
if (!session.rejected) {
session.rejected = true;
totalRejected += 1;
}
socket.destroy();
}
function closeSession(socket, session) {
if (session.closed) return;
session.closed = true;
sessions.delete(socket);
decrementAddressSessions(session.remoteAddress);
}
}
function normalizeConfig(input) {
const listenEnabled = input.listenEnabled === true;
const publicIngressEnabled = input.publicIngressEnabled === true;
if (publicIngressEnabled && !listenEnabled) {
throw new TypeError("device_gateway_public_ingress_listener_required");
}
if (
publicIngressEnabled
&& typeof input.onDiscovery !== "function"
) {
throw new TypeError("device_gateway_discovery_sink_required");
}
return {
listenEnabled,
publicIngressEnabled,
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18121,
0,
65535,
"device_gateway_health_port_invalid",
),
tcpHost: normalizeTcpHost(
input.tcpHost,
publicIngressEnabled ? "0.0.0.0" : "127.0.0.1",
publicIngressEnabled,
),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_gateway_tcp_port_invalid",
),
maxBufferedBytes: parseInteger(
input.maxBufferedBytes,
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
1024,
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
"device_gateway_buffer_limit_invalid",
),
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_gateway_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
10,
1,
1000,
"device_gateway_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
30,
1,
10000,
"device_gateway_address_rate_limit_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_gateway_session_timeout_invalid",
),
onDiscovery: typeof input.onDiscovery === "function"
? input.onDiscovery
: undefined,
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeHealthHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_gateway_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, fallback, publicIngressEnabled) {
const normalized = String(value || fallback).trim();
const allowed = publicIngressEnabled
? ["0.0.0.0", "::"]
: ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
publicIngressEnabled
? "device_gateway_public_ingress_host_invalid"
: "device_gateway_baseline_loopback_only",
);
}
return normalized;
}
function normalizeRemoteAddress(value) {
const normalized = String(value || "unknown").trim();
return normalized.slice(0, 64) || "unknown";
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (
!Number.isSafeInteger(parsed)
|| parsed < minimum
|| parsed > maximum
) {
throw new TypeError(errorCode);
}
return parsed;
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, resolve);
});
}
function closeServer(server) {
if (!server.listening) return Promise.resolve();
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,145 @@
import { readFile } from "node:fs/promises";
import { createCoreDiscoveryClient } from "./core-client.mjs";
import { createDeviceGatewayRuntime } from "./runtime.mjs";
const config = await readConfig();
const onDiscovery = config.listenEnabled
? createCoreDiscoveryClient({
coreUrl: config.coreUrl,
gatewayToken: config.gatewayToken,
timeoutMs: config.coreTimeoutMs,
})
: undefined;
const runtime = createDeviceGatewayRuntime({
listenEnabled: config.listenEnabled,
publicIngressEnabled: config.publicIngressEnabled,
healthHost: config.healthHost,
healthPort: config.healthPort,
tcpHost: config.tcpHost,
tcpPort: config.tcpPort,
maxBufferedBytes: config.maxBufferedBytes,
maxConcurrentSessions: config.maxConcurrentSessions,
maxSessionsPerAddress: config.maxSessionsPerAddress,
maxConnectionsPerMinutePerAddress:
config.maxConnectionsPerMinutePerAddress,
sessionTimeoutMs: config.sessionTimeoutMs,
onDiscovery,
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_gateway_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
async function readConfig() {
const listenEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
false,
);
const publicIngressEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED,
false,
);
return {
listenEnabled,
publicIngressEnabled,
healthHost: String(
process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1",
),
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
tcpHost: String(
process.env.DEVICE_GATEWAY_TCP_HOST
|| (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"),
),
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
maxBufferedBytes: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
65536,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
100,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
10,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30,
),
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
10000,
),
coreUrl: listenEnabled
? requiredValue(
process.env.DEVICE_GATEWAY_CORE_URL,
"device_gateway_core_url_required",
)
: "",
gatewayToken: listenEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
"device_gateway_core_token_file_required",
)
: "",
coreTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS,
5000,
),
};
}
async function readRequiredSecretFile(path, errorCode) {
const normalized = requiredValue(path, errorCode);
const value = (await readFile(normalized, "utf8")).trim();
if (value.length < 32) throw new Error(errorCode);
return value;
}
function requiredValue(value, errorCode) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(errorCode);
}
return value.trim();
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_gateway_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_gateway_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_gateway_boolean_invalid");
}
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
test("posts a discovery through the authenticated internal Core boundary", async () => {
let captured;
const observe = createCoreDiscoveryClient({
coreUrl: "http://device-control-core:18120",
gatewayToken,
fetchImpl: async (url, options) => {
captured = { url, options };
return new Response(JSON.stringify({
ok: true,
discovery: {
lifecycleState: "quarantine",
commandTransport: "disabled",
},
}), {
status: 201,
headers: { "Content-Type": "application/json" },
});
},
});
const signal = {
schemaVersion: "nodedc.device.discovery-signal.v1",
sessionRef: "session:test",
};
const discovery = await observe(signal);
assert.equal(
captured.url,
"http://device-control-core:18120/internal/v1/device-discoveries:observe",
);
assert.equal(
captured.options.headers.Authorization,
`Bearer ${gatewayToken}`,
);
assert.deepEqual(JSON.parse(captured.options.body), signal);
assert.equal(discovery.lifecycleState, "quarantine");
});
test("fails closed when Core does not return a quarantine view", async () => {
const observe = createCoreDiscoveryClient({
coreUrl: "http://device-control-core:18120",
gatewayToken,
fetchImpl: async () => new Response(JSON.stringify({
ok: true,
discovery: {
lifecycleState: "claimed",
commandTransport: "disabled",
},
}), { status: 200 }),
});
await assert.rejects(
() => observe({ schemaVersion: "test" }),
/device_gateway_core_ingest_contract_invalid/,
);
});
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { connect } from "node:net";
import test from "node:test";
import {
createControlCoreApp,
} from "../../device-control-core/src/app.mjs";
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("B2 HEADER2 becomes a persisted masked quarantine discovery before ACK", async () => {
let stored;
const core = createControlCoreApp({
discoveryIngestEnabled: true,
gatewayToken,
identifierPepper,
repository: {
health: async () => "ready",
upsertQuarantineDiscovery: async (value) => {
stored = value;
return {
created: true,
value: {
...value.safeView,
discoveryRef: "discovery:integration-001",
},
};
},
},
});
await listen(core);
const coreAddress = core.address();
const observe = createCoreDiscoveryClient({
coreUrl: `http://127.0.0.1:${coreAddress.port}`,
gatewayToken,
});
const gateway = createDeviceGatewayRuntime({
healthPort: 0,
tcpHost: "0.0.0.0",
tcpPort: 0,
listenEnabled: true,
publicIngressEnabled: true,
now: () => new Date(0x52db95de * 1000),
onDiscovery: observe,
});
const addresses = await gateway.start();
try {
const response = await exchange(
addresses.tcpAddress.port,
Buffer.concat([specificationHeader, specificationPackage]),
13,
);
assert.equal(
response.toString("hex").toUpperCase(),
"7B0400A0DE95DB527D7B00017D",
);
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
assert.equal(stored.safeView.lifecycleState, "quarantine");
assert.equal(stored.safeView.identifier.masked, "***********7769");
assert.equal(stored.safeView.commandTransport, "disabled");
assert.equal(
JSON.stringify(stored).includes("865209039777769"),
false,
);
} finally {
await gateway.stop();
await close(core);
}
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
}
function close(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
function exchange(port, payload, expectedBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let byteLength = 0;
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.write(payload);
});
socket.on("data", (chunk) => {
chunks.push(chunk);
byteLength += chunk.length;
if (byteLength >= expectedBytes) {
socket.destroy();
resolve(Buffer.concat(chunks, byteLength));
}
});
socket.on("error", reject);
});
}
@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { connect } from "node:net";
import test from "node:test";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("baseline health exposes no public ingress and no command transport", async () => {
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.publicIngress, "disabled");
assert.equal(body.commandTransport, "disabled");
assert.equal(body.tcpListener, "disabled");
assert.equal(addresses.tcpAddress, null);
} finally {
await runtime.stop();
}
});
test("discovery-only ingress persists HEADER2 before acknowledging packages", async () => {
const captured = [];
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
tcpHost: "0.0.0.0",
tcpPort: 0,
listenEnabled: true,
publicIngressEnabled: true,
now: () => new Date(0x52db95de * 1000),
onDiscovery: async (value) => captured.push(value),
});
const addresses = await runtime.start();
const client = await connectAndCollect(addresses.tcpAddress.port);
try {
client.socket.write(specificationHeader.subarray(0, 4));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(client.bytes().length, 0);
client.socket.write(specificationHeader.subarray(4));
await client.waitForBytes(9);
assert.equal(
client.bytes().subarray(0, 9).toString("hex").toUpperCase(),
"7B0400A0DE95DB527D",
);
assert.equal(captured.length, 1);
assert.equal(captured[0].identifier.value, "865209039777769");
assert.equal(captured[0].evidence.framingStatus, "verified");
assert.equal(captured[0].commandTransport, undefined);
client.socket.write(specificationPackage);
await client.waitForBytes(13);
assert.equal(
client.bytes().subarray(9).toString("hex").toUpperCase(),
"7B00017D",
);
assert.equal(runtime.status().totalDiscoveries, 1);
assert.equal(runtime.status().totalPackagesAcknowledged, 1);
assert.equal(runtime.status().commandTransport, "disabled");
assert.equal(runtime.status().publicIngress, "discovery-only");
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
const body = await response.json();
assert.equal(body.framing, "verified-read-only");
assert.equal(body.tcpListener, "discovery-only");
assert.equal(body.publicIngress, "discovery-only");
assert.equal(body.commandTransport, "disabled");
} finally {
client.socket.destroy();
await runtime.stop();
}
});
test("does not acknowledge malformed or unverified initial bytes", async () => {
const captured = [];
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
tcpPort: 0,
listenEnabled: true,
onDiscovery: async (value) => captured.push(value),
});
const addresses = await runtime.start();
try {
const received = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("not-a-b2-header", "utf8"),
);
assert.equal(received.length, 0);
assert.equal(captured.length, 0);
assert.equal(runtime.status().totalRejected, 1);
} finally {
await runtime.stop();
}
});
test("public ingress requires an authenticated discovery sink", () => {
assert.throws(
() => createDeviceGatewayRuntime({
listenEnabled: true,
publicIngressEnabled: true,
tcpHost: "0.0.0.0",
}),
/device_gateway_discovery_sink_required/,
);
});
test("baseline rejects non-loopback binding", () => {
assert.throws(
() => createDeviceGatewayRuntime({
listenEnabled: true,
tcpHost: "0.0.0.0",
}),
/device_gateway_baseline_loopback_only/,
);
});
test("container health may bind all interfaces while TCP stays disabled", async () => {
const runtime = createDeviceGatewayRuntime({
healthHost: "0.0.0.0",
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
assert.equal(addresses.healthAddress.address, "0.0.0.0");
assert.equal(addresses.tcpAddress, null);
assert.equal(runtime.status().publicIngress, "disabled");
} finally {
await runtime.stop();
}
});
function connectAndCollect(port) {
return new Promise((resolve, reject) => {
const chunks = [];
let byteLength = 0;
const waiters = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
resolve({
socket,
bytes: () => Buffer.concat(chunks, byteLength),
waitForBytes: (minimum) => {
if (byteLength >= minimum) return Promise.resolve();
return new Promise((waitResolve, waitReject) => {
waiters.push({ minimum, waitResolve, waitReject });
});
},
});
});
socket.on("data", (chunk) => {
chunks.push(chunk);
byteLength += chunk.length;
for (let index = waiters.length - 1; index >= 0; index -= 1) {
if (byteLength >= waiters[index].minimum) {
waiters[index].waitResolve();
waiters.splice(index, 1);
}
}
});
socket.on("error", (error) => {
for (const waiter of waiters.splice(0)) waiter.waitReject(error);
reject(error);
});
});
}
function sendAndCollect(port, payload) {
return new Promise((resolve, reject) => {
const chunks = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.end(payload);
});
socket.on("data", (chunk) => chunks.push(chunk));
socket.on("close", () => resolve(Buffer.concat(chunks)));
socket.on("error", reject);
});
}
+143
View File
@@ -0,0 +1,143 @@
# ADR: канон External Provider Data Plane
Статус: **superseded**.
Дата: 2026-07-13.
Владелец решения: NODE.DC Platform.
> Заменено 2026-07-14 документом
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
> Этот черновик неверно помещал provider adapter, normalisation и collection
> policy в `services/<provider>-gateway`. Новое правило: adapter принадлежит
> изолированному L2 workflow; Platform Data Plane остаётся provider-neutral.
## Контекст
Клиент может подключить к NODE.DC любой внешний продукт: телеметрию, ERP,
роботов, энергетику, BIM-систему или иной источник данных. Gelios Pro для
Gelios — первый конкретный поставщик для проверки канона, а не исключительная
архитектурная ветка. Нельзя превращать Engine workflow, Ontology Core или общую БД Platform
в место, куда попадают токены, raw payloads и частная логика каждого API.
Нужна повторяемая форма, в которой новый provider добавляется как отдельный
adapter, но получает общие правила scope, секретов, collection, хранения,
read-model, аудита и безопасной публикации в NDC.
## Решение
1. В `platform/packages/external-provider-contract` живёт общий versioned
контракт интеграции. Это не runtime и не база данных. Он задаёт форму
`provider`, `connection`, `capability catalog`, `credential reference`,
`access scope`, `field policy`, `collection profile`, `retention policy`,
`read model` и красный command-domain.
2. Каждый provider получает самостоятельный app-owned adapter в
`platform/services/<provider>-gateway`. Первый экземпляр —
`platform/services/gelios-gateway`. Adapter владеет intake contract, rate
budget, normalisation, collection policy, storage и своим internal
read/realtime API. Сам provider secret остаётся в Engine Credentials и
доступен только назначенному защищённому execution workflow.
3. Один provider service может обслуживать много клиентов. Каждая строка,
cursor, audit-event и read-model обязана иметь `tenant_id` и
`connection_id`; видимость provider account сама по себе не является
продуктовым scope. Для клиента с отдельными требованиями изоляции допустим
отдельный deployment/database profile без изменения контракта.
4. База принадлежит adapter-сервису, а не Ontology Core, Engine, Tasker или
общему Platform Postgres. Для пространственно-временного Gelios-кейса
базой служит PostgreSQL 16 + TimescaleDB + PostGIS (`gelios-postgres`).
Другой provider может выбрать иной storage engine только через явный ADR,
сохранив внешний контракт.
5. Ontology Core хранит только provider-neutral и provider-specific смыслы,
связи, правила и контракты. Он не хранит credentials, runtime telemetry,
customer raw payloads или renderer objects. Enforcement остаётся в
gateway/adapters. Engine Credentials хранит provider secret в границе
специально назначенного execution workflow; значение не сериализуется в
граф, ontology, логи, read-model или UI.
6. Engine L2 Collector использует credential reference и получает разрешённые
данные поставщика. Он передаёт в adapter только аутентифицированный нормализованный
intake payload. Остальные L2 workflow получают исключительно scoped
internal API/event contract и не читают gateway DB напрямую.
## Каноническая форма нового подключения
```text
Client / tenant
-> Engine Credential + protected Collector workflow
-> provider connection instance
-> provider adapter (safe intake policy + normalizer)
-> provider-owned storage and projections
-> internal read/realtime contract
-> L2 workflow / approved interface binding
-> renderer adapter
```
Каждый новый provider добавляет только свой adapter package, capability
catalog, schema mappings, scrubbed fixtures и domain ontology package. Он не
добавляет отдельную схему доступа к Engine/Studio и не создаёт прямой путь из
browser в provider API.
## Полнота данных без неконтролируемого объёма
«Предусмотреть все данные» означает каталогизировать каждую provider
capability и поле, а не опрашивать весь account на максимальной частоте.
Collection profile явно решает, какие safe-read capabilities, поля, scope и
частота включены в конкретной connection instance.
| Слой | Что хранится | Режим |
| --- | --- | --- |
| Capability catalog | documented endpoint/read-field/command capability и его риск | versioned source + ontology |
| Inventory/configuration | units, devices, groups, sensors, custom definitions | медленный reconcile |
| Current projection | последняя разрешённая позиция, состояние и display fields | idempotent upsert |
| Event history | нормализованные события и approved measurements | append-only, partitioned |
| Raw envelope | полный safe-read ответ с provenance и hash | restricted cold layer, retention-bound |
| Aggregates/features | rollups и признаки для аналитики/предиктива | derived, replaceable |
Raw envelope не выдаётся UI и не становится таблицей «всё в JSON навсегда».
Вначале он может быть compressed/partitioned storage с метаданными в БД; при
реальном объёме переносится в object storage, а PostgreSQL хранит immutable
index, hash, policy и ссылку. Retention, raw depth и downsampling утверждаются
после замера сообщений/сек, размера payload, требуемой истории, RPO/RTO и
стоимости. Так инженер может запросить ранее не показанное поле из
каталога/архива, не раздувая горячую read-модель.
## Realtime и интерфейс
Частота provider collection, обновления `current projection` и выдачи в UI —
три разные настройки. Например, map consumer может получать выбранную
read-модель раз в 3 секунды, но это не даёт ему права опрашивать Gelios раз в
3 секунды или создавать отдельный polling loop на каждого зрителя.
Gateway сначала обновляет одну current projection и публикует change event.
L2/Map binding затем может sampling/throttle этот поток по утверждённой
настройке интерфейса. Источник истины для live state — gateway storage, не
долгоживущий workflow и не Cesium session.
## Commands: моделируются, но не подключаются
Command templates, параметры, delivery states и audit входят в capability
catalog и ontology полностью. Read adapter не содержит send route и не
использует command capability. В будущем command execution создаётся только
как отдельный `provider-command-gateway`/red-domain deployment с явным
человеческим подтверждением, role/scope check, idempotency, audit и отдельным
security review. До этого команда не может быть отправлена из collector, L2,
Map или AI Workspace.
## Обязательные артефакты каждого adapter
- `provider manifest`: provider id, adapter version, auth modes, rate limits;
- capability and field catalog: read/write classification, source evidence,
pagination and error semantics;
- connection profile: tenant, secret reference, approved scope, field policy,
collection and retention profile;
- normalised contract and migrations; scrubbed fixtures and contract tests;
- health/metrics/audit without secrets or raw personal data;
- ontology package with stable subjects, relations and guardrails;
- internal read/realtime API contract; no browser/provider bypass.
## Не решено этим ADR
- конкретный deployment topology и HA/PITR target для каждого volume;
- выбор object storage после real-volume measurement;
- L2 stream execution contract и Module Studio binding implementation;
- параметры first Gelios collection profile и owner-approved connection scope.
Gelios-specific применение этого решения описано в
`docs/ADR_GELIOS_DATA_PLANE.md`.
+107
View File
@@ -0,0 +1,107 @@
# ADR: Gelios adapter в External Provider Data Plane
Статус: **superseded**.
Дата: 2026-07-13.
Владелец решения: NODE.DC Platform.
> Заменено 2026-07-14 документом
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
> Gelios остаётся domain/ontology примером, но не получает отдельный
> provider-owned gateway: fetch, mapping и collection policy живут в его L2
> connector instance; Platform предоставляет нейтральный Data Plane.
## Контекст
Gelios Pro поставляет непрерывную телеметрию, конфигурацию устройств и пространственные данные. В текущем доступе подтверждены 107 видимых units, из них 105 с `lastMsg`; окончательный connection scope должен быть зафиксирован allowlist-ом владельца. Это не данные Ontology Core и не данные Tasker. Они требуют отдельного контура для текущего состояния, истории, геозапросов, аналитики и будущих прогнозов.
Gelios является первым provider adapter, который подчиняется общему
`docs/ADR_EXTERNAL_PROVIDER_DATA_PLANE.md`: connection instance хранит
connection scope/policy, Engine Credentials владеет provider secret, gateway
владеет storage/read-моделью, а ontology описывает значения, но не runtime
data.
Физические trike-команды существуют в домене, но **не входят в ingestion или тестирование**. Для них позднее потребуется отдельный, ручной и аудируемый контур.
## Решение
1. Создать отдельный сервис `platform/services/gelios-gateway` как storage/read gateway:
- не хранит и не получает provider token;
- принимает только аутентифицированный safe-read intake от защищённого Engine L2 Collector workflow;
- применяет allowlist и field policy до записи;
- нормализует ответ в сущности пакета `gelios`;
- публикует read-модель для Engine L2 workflow и Map View.
2. В Engine появляется отдельный Gelios Credential и NDC Agent L2 collection profile:
- credential скрывает access/refresh pair и его lifecycle;
- credential привязан только к намеренно пошаренному Collector workflow;
- Collector содержит allowlist read-capabilities и не имеет command transport;
- ни секрет, ни provider response без нормализации не передаются в ontology, UI, Gateway или другие workflow.
3. Выделить сервису собственную БД `gelios-postgres`, не деля её с Tasker, Authentik, Notification Core или Ontology Core. В будущей multi-tenant форме эта БД обслуживает несколько Gelios connection instances, но все records разделены `tenant_id` и `connection_id`.
4. Базовый движок: PostgreSQL 16 с расширениями TimescaleDB и PostGIS.
- Timescale hypertable хранит временные ряды и автоматически делит их по времени.
- PostGIS хранит нормализованные точки и геометрии зон, а не renderer-объекты Cesium.
- Данный выбор покрывает транзакционную конфигурацию, realtime upsert, исторические запросы, SQL-аналитику и географию одним контуром.
5. **Не** использовать RabbitMQ Tasker как общую шину Gelios. Если измерения покажут, что прямой writer или число независимых потребителей не справляются, добавить в Gelios-контур NATS JetStream с durable pull consumers. Он даст replay, acknowledgement и контролируемое удержание сообщений.
6. Engine level-2 Collector — единственная точка provider access; остальные workflow являются потребителями read-модели. Ontology Core остаётся только словарём и контрактами.
## Целевой поток
```text
Gelios REST (safe read only)
-> NDC Agent L2 collection profile + protected credential
-> authenticated normalized intake
-> Gelios Gateway: scope -> field policy -> normalizer
-> gelios-postgres: current state + immutable telemetry history
-> [при необходимости] NATS JetStream
-> `fleet.positions.current.v1` read API / realtime subscription
-> NDC workflow level 2
-> Map View semantic binding
-> Cesium renderer adapter
```
Ни один шаг не получает права отправить команду устройству. Красный command-domain находится вне этого потока; metadata каталога команд сохраняется, но send transport не создаётся.
## Модель хранения v0
| Слой | Назначение | Минимальные записи |
| --- | --- | --- |
| Контроль | граница и повторяемость сбора | `access_scope`, `collection_run`, `ingestion_cursor`, endpoint/response metrics |
| Каталог | стабильные сущности и их конфигурация | `unit`, `unit_group`, `tracker_device`, sensor/maintenance/custom-field definitions |
| Current state | одна актуальная запись на unit для карты и интерфейса | `unit_current`, `position_fix`, approved operational status |
| History | неизменяемые события с временем наблюдения и получения | `telemetry_snapshot`, selective `sensor_reading`, restricted raw payload reference |
| Spatial | геометрии для запросов, не Cesium graphics | point/track/geozone with SRID 4326 |
| Analytics | роллапы и признаки, не запросы по всему raw | hourly/daily aggregates, feature sets, model runs |
| Audit | попытки, ошибки, политика, будущие команды | collection audit; separate command audit later |
`unit_current` обновляется idempotently по `unitSubjectId`. История записывается append-only с ключом дедупликации, включающим provider unit id, observed time и fingerprint сообщения. Все временные таблицы имеют `observed_at` и `received_at`: задержка поставщика не должна переписывать фактическое время на карте.
## Индексы и жизненный цикл
- Основной путь истории: `(unit_subject_id, observed_at DESC)`.
- Пространственный индекс только для нормализованной geography/geometry; рендер-кэши в БД не храним.
- Сырые `params`/raw messages — restricted, отдельно от публичной Studio read-model.
- Политики retention, downsampling и резервного копирования должны быть утверждены до запуска history backfill. Они зависят от фактических msg/s, размера payload, нужной глубины истории, RPO/RTO и стоимости хранения.
- Для предиктива держать recent/raw слой и отдельные часовые/дневные агрегаты. Timescale continuous aggregates позволяют сохранять длительную агрегированную историю после сокращения raw при корректно согласованных refresh и retention политиках.
## Нулевая итерация без лишней инфраструктуры
1. Зафиксировать owner-approved allowlist и видимые поля.
2. Сделать только safe-read Engine Collector с ограничением по scope, rate limit, paging и cursor.
3. В течение согласованного окна измерить: сообщений/сек, размер ответа, lag, дубликаты, задержку записи и нагрузку запросов карты.
4. На фактах включить Timescale hypertables, PostGIS и retention policy; после этого решить, нужен ли JetStream сразу.
5. Подать только `fleet.positions.current.v1`/approved current position в Map View. Историю и raw не отдавать в renderer напрямую.
## Что не решено этим ADR
- окончательное правило connection scope;
- частота polling/возможность provider push;
- сроки хранения raw, нормализованной истории и агрегатов;
- RPO/RTO, репликация и production backup plan;
- допуск к ручному command gateway. До отдельного решения отправка команд запрещена.
## Обоснование и источники
- [Timescale hypertables](https://docs.timescale.com/use-timescale/latest/hypertables/) — временные таблицы PostgreSQL автоматически партиционируются по времени.
- [Timescale self-hosted installation](https://docs.timescale.com/self-hosted/latest/install/) — расширение разворачивается как self-hosted PostgreSQL-контур; production требует backup/PITR и HA-плана.
- [Retention и continuous aggregates](https://docs.timescale.com/use-timescale/latest/data-retention/data-retention-with-continuous-aggregates/) — raw и агрегаты требуют согласованных lifecycle-политик.
- [PostGIS](https://postgis.net/docs/en/) — PostgreSQL-расширение для spatial types и GiST R-tree индексов.
- [NATS JetStream consumers](https://docs.nats.io/nats-concepts/jetstream/consumers) — durable consumers дают acknowledgement, повторную доставку и recovery; рекомендуются pull consumers для новых масштабируемых обработчиков.
+238
View File
@@ -0,0 +1,238 @@
# ADR: внешние коннекторы принадлежат NDC L2 workflow
Статус: **accepted**.
Дата: 2026-07-16.
Владелец решения: NODE.DC Platform.
## Контекст
NODE.DC состоит из двух разных слоёв:
- платформенный слой даёт NDC L1, NDC L2, Ontology, credentials, Data Products
и Foundry-boundaries;
- пользовательские автоматизации собирают из этих возможностей конкретную
бизнес-логику.
Новый account, provider credential, tenant, расписание или интерфейс не должны создавать
новый platform service и не должны требовать изменения NODE.DC source. Команда
NODE.DC подключается только для нового поставщика либо для расширения
подтверждённых capabilities и ontology уже поддержанного поставщика.
Gelios — первый живой источник и acceptance-кейс этого канона, а не отдельная
архитектурная ветка. Документ заменяет provider-runtime решения из
`ADR_EXTERNAL_PROVIDER_DATA_PLANE.md` и `ADR_GELIOS_DATA_PLANE.md`; те
документы остаются только историей решения.
## Решение
### 1. Пакет поставщика создаётся один раз
Каждый поддержанный поставщик получает один версионируемый **provider package**.
Он является знанием платформы о внешнем API и содержит:
- стабильный `providerId` и версии подтверждённого API;
- credential contract без значения секрета;
- каталог safe-read capabilities, pagination/rate-limit metadata и response
shapes;
- mappings из provider fields в версии Ontology;
- совместимые Data Products и scrubbed contract fixtures;
- явно отделённый каталог команд без активного command transport.
Пакет не является runtime service, scheduler, базой данных или customer
configuration. Он расширяется только когда фактически подтверждён новый API,
новый тип данных или новая ontology revision.
Одна учётная запись поставщика задаётся данными, а не кодом:
`provider package + credential reference + connection profile + NDC L2 workflow instance`
Поэтому второй account или другая provider credential pair создаёт ещё один credential/profile и
workflow instance. Платформенный source, Data Plane и Foundry при этом не
меняются.
### 2. NDC L1 проектирует, NDC L2 workflow исполняет
NDC L1 получает пользовательское намерение, проверяет доступные capabilities и
Ontology, проектирует или изменяет разрешённый NDC L2 workflow через NDC MCP и
анализирует execution evidence.
Один изолированный NDC L2 workflow представляет один connection instance и
владеет исполняемой механикой:
- safe-read вызовами provider API через credential reference;
- pagination, cursor, retry, rate limit, batch size и collection cadence;
- проверкой response shape и разбиением ответа на items;
- mapping к точной ontology revision;
- idempotency, watermark и публикацией canonical facts;
- private workflow state, когда он действительно нужен.
Provider-specific mapping сначала реализуется штатными workflow nodes и малым
Code-преобразователем. Это позволяет проверить реальный API без преждевременного
создания custom nodes. В custom NDC nodes переносится только повторившаяся и
доказанная **платформенная boundary-механика**, а не уникальная логика
поставщика.
### 3. Credentials являются данными connection instance
Gelios выдаёт ровно два provider secrets: access token и refresh token. Текущий
HTTP request binding `httpBearerAuth` использует access token. Автоматический
refresh в принятом runtime не доказан, поэтому provider package фиксирует его
как `operator_managed`; ни один из token values не попадает в graph, package,
Ontology, Data Plane, Foundry, execution logs, MCP или Ops. Новый account или
provider-issued token pair означает новые native credential records и
connection instance, но не новую Platform-сущность.
Локальное имя credential (например, с суффиксом `read access`) не является
provider scope. В target-контракте Read-классификацию задаёт allowlisted
method/endpoint в capability catalog и Engine workflow policy; тот же access
token нельзя называть отдельным «read token» или «write token» только из-за
label. Сейчас deployed Engine safe-ref policy ограничивает generic HTTP
credential только по host, но ещё не связывает его с package/version и exact
method/path. Поэтому Gelios `GET /api/v1/units` пока является декларативной
capability, а не завершённой runtime-security гарантией; canonical acceptance
требует capability-bound Engine policy и её MCP proof.
Writer capability для публикации Data Product — отдельный внутренний native NDC
L2 credential, не третий Gelios token. Generic Engine/Platform control plane
генерирует capability внутри native credential boundary, сохраняет её там же и
передаёт EDP только SHA-256 digest вместе с точным provider, connection и
набором Data Products. Пользователь и MCP получают только opaque
reference/status. Capability не записывается в graph, provider package, env,
file, Ops или trace и не копируется через пользовательский UI.
Широкий credential sink, resolver/daemon с произвольной записью secret и любой
provider-specific credential service запрещены. Нужна одна узкая операция
`ensure data-product publish grant`: scope выводится из granted L1→L2 target,
зарегистрированного connection profile и разрешённого Data Product; issuance,
rotation и native binding должны быть idempotent, CAS/crash-safe и auditable.
Engine генерирует capability внутри credential boundary и передаёт EDP только
SHA-256 digest через idempotent binding key + generation; EDP не возвращает
plaintext. Новая generation создаётся до переключения node reference, а старая
отзывается только после успешного bind/acceptance.
Старые ручные EDP `POST/rotate` endpoints, которые возвращают capability один
раз, включаются отдельным legacy-флагом и не входят в canonical acceptance.
Digest-only managed endpoint имеет независимый флаг и принимает только
Ed25519-signed Engine service requests: exact audience/method/request target/raw
body hash входят в подпись, timestamp ограничен по skew, nonce защищён bounded
fail-closed replay cache. Legacy provisioner bearer на managed routes не
действует. EDP получает только deployment public key; matching private key
остаётся только внутри Engine server boundary. Endpoint остаётся выключенным,
пока key provisioning, Engine signer и exact native credential binding policy
не пройдут runtime acceptance.
В deployed Engine MCP этой операции пока нет — это текущий platform gap перед
canonical publish proof, а не действие пользователя. Это решение не заявляет
автоматический refresh Gelios: до отдельного runtime proof он остаётся
`operator_managed`.
### 4. External Data Plane нейтрален к поставщику
Platform предоставляет один versioned External Data Plane. Он принимает
canonical facts через scoped writer binding, хранит current/history
projections и публикует snapshot/patch contracts. В нём запрещены:
- ветвления по provider, customer, account, entity или renderer;
- provider field mapping и бизнес-фильтрация;
- provider credential, endpoint, schedule или command transport;
- caller-supplied tenant/connection scope.
`NDC Data Product Publish` отправляет только
`nodedc.data-product.publish/v1`. External Data Plane materializes immutable
scope из writer binding, проверяет разрешённый Data Product и его ontology
revision, затем сохраняет batch.
EDP runtime импортирует только package subpath
`@nodedc/external-provider-contract/data-plane`. Его deploy artifact и image
содержат только provider-neutral wire validators; `providers/gelios`, mappings,
fixtures и tests туда не входят. Изменение или добавление provider package не
пересобирает и не перезапускает EDP: каталог поставщиков разворачивается через
Engine/Ontology/control-plane путь отдельно.
Collection cadence, history cadence и presentation cadence независимы:
- NDC L2 забирает источник с частотой, нужной бизнес-задаче;
- current projection принимает каждое валидное изменение;
- declarative history policy может хранить все точки или sampling;
- Foundry читает snapshot+patch и отдельно ограничивает частоту render.
Для разной частоты БД и интерфейса не создаются второй provider sink, отдельный
gateway или параллельный прямой push в renderer.
### 5. Полнота означает все сущности разрешённой capability
Connection profile выбирает safe-read capabilities, а не зашитый в Platform
список entity IDs. Если разрешённый endpoint возвращает все доступные credential
сущности, NDC L2 обрабатывает каждый валидный item. Новый объект появляется
автоматически.
Пользовательская фильтрация, слои и видимость находятся после сбора — в
automation/Data Product/Foundry. Технические ограничения допустимы только как
pagination, quota, batch size, backpressure и защита от повреждённого ответа.
### 6. Custom NDC nodes ограничены платформенными границами
Первый канонический набор:
- `NDC Data Product Publish` — NDC L2 → External Data Plane;
- `NDC Data Product Read` — scoped snapshot/patch read;
- `NDC Foundry Binding` — control-plane связь Data Product с
`Application → Page → typed slot`.
Эти nodes не содержат provider ID, tenant ID, connection ID, endpoint, token,
mapping или cadence. Runtime package сохраняет технический namespace
`n8n-nodes-ndc.*`, но пользовательские документы и интерфейсы используют
только терминологию NDC.
`NDC Foundry Binding` не транспортирует каждый realtime tick. Он создаёт
постоянную связь интерфейса с Data Product; Foundry затем читает его
snapshot+patch contract.
### 7. Реальный Gelios acceptance-кейс
Первая версия provider package должна доказать путь:
`Gelios safe read → все доступные units → map.moving_object facts →
fleet.positions.current.v1 → Foundry map`
Canonical product использует ontology revision
`ontology.map.moving_object.v1` и только объявленные snake_case поля. NDC L2
не передаёт caller scope и не собирает собственный batch envelope вокруг
`NDC Data Product Publish`.
Acceptance выполняется по порядку:
1. Platform выполняет generic `ensure data-product publish grant`, выпускает
scoped EDP binding и атомарно сохраняет capability в native NDC L2
Credentials;
2. NDC MCP видит только совместимый opaque writer credential reference/status;
3. применить подтверждённый graph patch без legacy intake;
4. validate/preflight;
5. один успешный manual run и проверка execution/trace/Data Product;
6. только затем отдельным изменением добавить Schedule Trigger;
7. после доказанного snapshot+patch подключить Foundry binding.
### 8. Capacity и topology
Количество одновременно активных NDC L2 проектов ограничивается фактическими
ресурсами железа и профилем нагрузки. Пока capacity проверяется оператором и не
автоматизируется. Канон не объявляет отдельные worker/webhook generations или
high-load topology, которых ещё нет в принятом runtime.
## Последствия
- `services/<provider>-gateway` не является частью канона и не создаётся для
новых provider integrations. Существующий self-hosted Gelios Gateway и его
Timescale volume остаются frozen compatibility contour, воспроизводятся из
source/deploy и не изменяются новым L2 pilot без отдельного решения.
- Новый account или provider-issued access/refresh pair — configuration change,
а не platform release.
- Новый provider или неподдержанная capability — versioned provider/Ontology
change с contract tests.
- Существующий legacy L1/SDK workflow является frozen compatibility boundary:
новый L2 pilot его не редактирует, не отзывает его credential и не ставит его
вывод условием acceptance.
- Legacy `/internal/data-plane/v1/intake` остаётся выключенным migration-only
route и не используется новыми NDC L2 workflows.
- Команды устройствам остаются отдельным red-domain контуром с явным
подтверждением, scope, idempotency и аудитом.
- Foundry развивается после доказанного Data Product path; provider API и
credentials в Foundry не попадают.
+29
View File
@@ -27,6 +27,18 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica
- The deployed AI Hub is allowed only as transport. Launcher, Engine, Ops, Authentik, task manager, and downstream app calls must stay local.
- The remote worker must not call product apps directly. Assistant actions must route back through the local AI Workspace Assistant.
`local-seo/deployed-hub-control-plane`:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO AI Workspace profile: `deployed-hub`
- SEO backend Assistant-compatible control-plane URL: `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`
- SEO backend control-plane token: `SEO_AI_WORKSPACE_CONTROL_TOKEN`, accepted by the public Hub server-side API.
- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- Public Hub exposes only token-gated Assistant control-plane proxy endpoints required by SEO model-provider setup, probe, thread dispatch, and message polling.
- Public Hub must proxy those calls to internal Platform Assistant with server-side internal token. Browser clients and remote workers must not receive the Hub internal token or Assistant token.
- This profile is for local SEO development against the deployed AI Workspace service plane. It is not the same as `local`, and it must not silently fall back to localhost or product app URLs.
`synology/prod-public`:
- Browser Launcher: `https://hub.nodedc.ru`
@@ -57,6 +69,23 @@ New apps must join AI Workspace through app manifests/config/adapters:
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
### NDC SEO Mode
`seo-mode` is a first-class AI Workspace surface, not `global`.
Local SEO testing should keep SEO app/backend on fixed local ports while using the configured AI Workspace transport/profile:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO model-provider surface: `seo-mode`
- SEO model task mode: `seo-model-task`
- Worker-facing transport: public AI Hub relay when selected by profile, normally `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- SEO task payload: sanitized `seo-model-task` contract only
The SEO backend must select profiles through env/config, not code edits. Local and deploy-host may use different Assistant URLs, executor ids, setup commands, model defaults, and app grants, but the request contract stays the same.
Remote Codex for SEO must not receive Wordstat/Yandex credentials, product app tokens, unrestricted repo access, rewrite/apply actions, or destructive actions. Wordstat/SERP collection remains SEO-backend-owned.
## Verification
Before changing worker, Assistant, Hub, or app adapter routing, run:
+82
View File
@@ -0,0 +1,82 @@
# AI Workspace ↔ Ontology Core MCP
## Purpose
This is the read-only semantic path for a NODE.DC AI Workspace run:
```text
Codex worker
-> dynamic per-run MCP configuration
-> public AI Workspace Hub (pairing-bound route)
-> internal Ontology Core MCP
-> freshly loaded ontology catalog and domain packages
```
`ai-workspace-assistant` creates this MCP server entry at run-profile time for every Hub-connected executor when `AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true`. It is a platform runtime grant (`ontology:catalog:read`), not a user-owned installer secret and not a hard-coded copy of ontology rules in a prompt.
The worker receives only a pairing-bound Hub URL. The Hub verifies that the pairing agent is online and replaces any external authorization with the platform-internal token before forwarding to `ontology-core`. Ontology Core is internal-only and has no host port or reverse-proxy route.
## Read-only MCP surface
`nodedc_ontology` exposes only these tools:
- `ontology_status` — catalog counts, domain packages and a safe catalog hash;
- `ontology_search` — canonical entities, relations and aliases;
- `ontology_get_entity` — entity definition, aliases, relations and guardrails;
- `ontology_get_guardrails` — semantic/safety rules and blocked conflations;
- `ontology_resolve_context` — semantic route advice with source identifiers removed.
It intentionally does not expose filesystem paths, evidence ledgers, credentials, raw payloads, live telemetry, databases, command dispatch, workflow mutation or Studio controls.
## Boundary for external providers and runtime data
Ontology Core describes the canonical meanings and constraints:
```text
gelios.unit -> gelios.telemetry_snapshot -> gelios.position_fix -> map.moving_object
```
It does **not** serve provider runtime data or call a provider API. Provider
access and mapping belong to the granted NDC L2 workflow; shared current and
history products belong to the provider-neutral External Data Plane. A scoped
Data Product read capability may later be supplied to an AI Workspace run as a
separate dynamic MCP server. Ontology supplies meanings, relations, guardrails
and the allowed contract route; the data capability enforces access and returns
live data.
This preserves one-way responsibility:
- Ontology Core: semantics, contracts, aliases, guardrails and context advice.
- External Data Plane: access-scoped Data Product snapshots and updates.
- Command Gateway: separate red-domain command route, explicit confirmation and audit.
- NDC L2 workflow: provider access, collection and mapping using granted capabilities.
- Foundry: presentation consumer, outside this implementation.
## Live catalog updates without AI Workspace rule redeploy
`loadCatalog()` runs for each Ontology MCP tool call; it does not cache the merged catalog. Therefore a new or edited domain package is visible to existing AI Workspace rules as soon as the catalog content is updated in the running Ontology Core container or mounted runtime catalog directory.
For a catalog-only release, update/restart **Ontology Core only**. The AI Workspace Assistant, Hub and Codex worker configuration do not need a rules redeploy because the MCP tool names and route stay stable. `NODEDC_ONTOLOGY_CATALOG_ROOT` may point Ontology Core at a separately managed catalog directory when a runtime-mounted catalog is required.
A Hub/Assistant rollout is needed only if the MCP transport, authorization contract or tool definitions themselves change.
## Safety properties
- The MCP server accepts only internal bearer authentication from AI Hub.
- A browser `Origin` is rejected by default; allow specific origins only through `ONTOLOGY_MCP_ALLOWED_ORIGINS` if an intentional browser transport is introduced.
- The Hub does not pass browser cookies or external `Authorization` downstream.
- The Hub route is pairing-bound and is unavailable when the worker is offline.
- All current tools are read-only. There is no generic ontology write API.
## Validation
```text
cd platform/services/ontology-core
npm run validate
npm run smoke:mcp
cd ../../
node --check services/ai-workspace-assistant/src/server.mjs
node --check services/ai-workspace-hub/src/server.mjs
docker compose --env-file infra/.env -f infra/docker-compose.dev.yml config
```
+100
View File
@@ -51,6 +51,51 @@ NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false
# External Data Plane — provider-neutral storage owned by the Platform. This
# password is a database credential only; never reuse NODEDC_INTERNAL_ACCESS_TOKEN.
EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH=5000
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT=65536
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS=500
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES=262144
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS=3600000
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS=604800000
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT=10000
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS=20000
EXTERNAL_DATA_PLANE_STREAM_POLL_MS=1000
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS=10
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false
# Internal control-plane writer/reader binding issuance; keep false until the
# atomic NDC L2 ensure-grant operation is deployed. Legacy path returns a
# plaintext capability and must never be exposed to users.
EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false
# Digest-only managed writer-binding ensure. It accepts only signed Engine
# service requests; the legacy provisioner bearer is deliberately invalid here.
# Keep false until the matching Engine private key is provisioned. The trust
# directory is mounted read-only into EDP and contains only `public-key.pem`.
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED=false
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID=nodedc-engine
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID=engine-edp-managed-provisioner-v1
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS=60
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED=true
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID=nodedc-module-foundry
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID=foundry-edp-managed-provisioner-v1
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_MAX_SKEW_SECONDS=60
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
# notification core
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
@@ -66,8 +111,15 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
# Add Module Foundry only after its domain, Launcher handoff and Authentik group
# are verified. Preserve existing adapters when adding this JSON member:
# {"module-foundry":{"url":"https://<foundry-domain>/api/ai-workspace/entitlements","required":false}}
# The generic adapter reuses the existing NODE.DC internal server credential;
# never define a separate Foundry token for a browser or worker.
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON=
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
# AI Workspace Hub for downloaded Codex workers.
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
@@ -78,3 +130,51 @@ AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS=
# The worker receives a per-run, pairing-bound read-only Ontology MCP URL through AI Hub.
# Do not put Ontology Core tokens or catalog paths in worker configuration.
AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL=
ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104
# Gelios Gateway — frozen legacy storage/read compatibility service. Provider
# credentials belong to the protected Engine Collector and are never configured
# in this service. Keep this contour reproducible; do not use it as a template
# for new providers.
GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
GELIOS_PG_DB=nodedc_gelios
GELIOS_PG_USER=nodedc_gelios
GELIOS_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
# URL-encode reserved characters in GELIOS_PG_PASS when forming this URL.
GELIOS_DATABASE_URL=postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios
GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105
GELIOS_TENANT_ID=replace-with-tenant-id
GELIOS_CONNECTION_ID=gelios-connection-id
# `allowlist` accepts only GELIOS_ALLOWED_UNIT_IDS. `all` accepts every unit
# returned by this already-approved tenant + connection, including future units.
GELIOS_UNIT_SCOPE=allowlist
GELIOS_ALLOWED_UNIT_IDS=
GELIOS_INTAKE_ENABLED=false
GELIOS_RAW_RETENTION_DAYS=14
# Presentation state only; it does not change provider collection cadence.
GELIOS_POSITION_STALE_AFTER_MS=300000
# map gateway — keep the actual ion token only in local/staging environment files or Docker secrets.
# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key.
MAP_GATEWAY_HOST_BIND=127.0.0.1:18103
MAP_GATEWAY_ALLOW_ANONYMOUS=true
MAP_GATEWAY_CORS_ORIGIN=http://127.0.0.1:3333,http://localhost:3333
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
# Imported Engine imagery is selected through the explicit offline cache
# profile, not through a global host-side block.
MAP_GATEWAY_LEGACY_CACHE_HOSTS=
# Leave empty unless the provider/data licence explicitly permits disconnected/offline use.
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS=30
CESIUM_ION_TOKEN=
CESIUM_ION_ASSET_ALLOWLIST=1,2,96188
MAP_CACHE_MODE=readwrite
# Mutable cache is mounted at /var/lib/nodedc-map-live-cache by compose.
# MAP_OFFLINE_SNAPSHOT_DIR is intentionally wired by compose as a read-only
# imported Engine snapshot; never place secrets or the live cache there.
MAP_CACHE_MAX_MB=20480
MAP_CACHE_MAX_OBJECT_MB=128
MAP_CACHE_DEFAULT_TTL_SECONDS=604800
+10
View File
@@ -6,6 +6,7 @@
- reverse proxy;
- локальные домены;
- shared env examples;
- Map Gateway с persistent TileCache;
- будущие docker compose файлы.
Первый local dev слой проксирует текущие локальные приложения без физического переноса репозиториев:
@@ -70,10 +71,19 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps
curl -I -H 'Host: auth.local.nodedc' http://127.0.0.1/
curl -I -H 'Host: launcher.local.nodedc' http://127.0.0.1/
curl -I -H 'Host: task.local.nodedc' http://127.0.0.1/
curl http://127.0.0.1:18103/healthz
```
Generated Authentik bootstrap credentials are stored only in `infra/.env`.
## Map Gateway и offline TileCache
`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. На NAS его mutable live-cache лежит в `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache`; read-only offline snapshot — рядом в `offline-snapshot`. Это host bind mounts, поэтому папки видны через SMB как `nodedc-platform/map-gateway/`, но не попадают в Git, Docker image или deployment artifact.
Обе папки создаёт root-owned `nodedc-deploy` при первом Map Gateway artifact. Agent не создаёт их напрямую через SMB и не кладёт в artifact. `docker compose down -v` их не удаляет. Очистка допустима только отдельной явно согласованной root-операцией при остановленном Gateway.
Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Browser получает лишь публичный provider URL через same-origin Foundry proxy; Gateway добавляет asset credential только в исходящем private request. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`.
5. Bootstrap local Authentik groups and OIDC applications:
```bash
+12 -1
View File
@@ -27,6 +27,13 @@ GROUP_SPECS = [
("nodedc:taskmanager:admin", False),
("nodedc:taskmanager:user", False),
("nodedc:bim:access", False),
# Module Foundry roles travel through the existing Authentik `groups`
# claim and Launcher handoff. `access` remains a backward-compatible
# member role until every existing assignment is migrated.
("nodedc:module-foundry:admin", False),
("nodedc:module-foundry:user", False),
("nodedc:module-foundry:blocked", False),
("nodedc:module-foundry:access", False),
]
APP_SPECS = [
@@ -121,7 +128,11 @@ def ensure_user_groups(groups):
user.groups.add(authentik_admins)
for name in groups:
user.groups.add(groups[name])
# The bootstrap owner must remain capable of entering Foundry after
# the first provisioning run. Blocking is an explicit operator action,
# never a default membership of the bootstrap principal.
if name != "nodedc:module-foundry:blocked":
user.groups.add(groups[name])
return user
+549
View File
@@ -20,6 +20,56 @@ The runner accepts data-only app-overlay artifacts from:
/volume1/docker/nodedc-deploy/inbox
```
## Dedicated Device Edge runner
The Debian Device Edge is a separate root-owned deployment domain. It does not
use the Synology runner, inbox, state or backup tree. Its live runner and fixed
roots are:
```text
/usr/local/sbin/nodedc-edge-deploy
/home/ndcsudo/nodedc-device-edge/deploy/inbox
/home/ndcsudo/nodedc-device-edge/source
/var/lib/nodedc-edge-deploy
```
`nodedc-edge-deploy` accepts only `component=device-edge`, validates an exact
five-entry payload and can build/recreate only `device-edge-relay`. The existing
`device-edge-backhaul` and `tailnet` containers are identity-snapshotted before
the transition and must remain byte-for-byte runtime-equivalent through apply
or automatic rollback. It has no registry entry in the Synology runner.
The first ingress transition uses a Docker IPvlan L2 address on the Mini's
single Ethernet parent. It publishes no Docker host port and preserves the
Amnezia host full tunnel; only the relay container receives a LAN-routable
address. The fixed IPv4 is a runner/Compose/descriptor constant:
`192.168.71.253`. Router evidence on 2026-08-04 proves the Deco DHCP pool is
`192.168.68.50` through `192.168.71.250`, so the address is explicitly outside
the pool. Router port-forwarding/firewall remains a separate manual gate.
Build and test the transition source:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
npm test --prefix device-plane
```
After the fixed address is approved, build the production artifact with a fresh
transition id, stage it into the Edge inbox, then use only the canonical pair:
```bash
sudo /usr/local/sbin/nodedc-edge-deploy plan \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-edge-deploy apply \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
```
The apply acceptance checks the exact IPvlan parent/subnet/gateway/address,
absence of host port publication, internal relay health, private backhaul
reachability, preserved VPN routes and unchanged backhaul/tailnet container
identity. Gelios and Device Plane command transport are outside this domain.
Supported components in this source:
- `engine`
@@ -28,19 +78,518 @@ Supported components in this source:
- `tasker`
- `ops-agents`
- `bim-viewer`
- `n8n-private-extension`
- `module-foundry`
- `device-plane`
- `proxy-contur`
- `dc-amd-proxy`
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
n8n private-node releases. Its artifact may contain exactly one digest-bound
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
`rollback.json`. The runner validates the inner npm tarball, rejects lifecycle
scripts and runtime dependencies, refuses to overwrite an existing release,
and seals the installed release root-owned/read-only under:
```text
/volume1/docker/nodedc-platform/n8n-private-extensions/releases/n8n-nodes-ndc/<version>-<sha256-prefix>
```
This component has no Compose file, service, container mutation or activation
side effect. In particular, staging does **not** make the node visible to n8n.
Activation remains an Engine-owned change: mount the reviewed immutable release
at `/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, atomically switch between
verified releases, restart every n8n process, and accept only after MCP exposes
the package-qualified `n8n-nodes-ndc.*` schemas. The Platform runner cannot
cross that boundary and never runs `npm install` in a live container.
Build a verified offline release artifact:
```bash
node infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
n8n-nodes-ndc-release-YYYYMMDD-NNN
```
The builder is byte-reproducible and accepts exactly the three reviewed NDC
runtime types:
- `n8n-nodes-ndc.ndcDataProductPublish`
- `n8n-nodes-ndc.ndcDataProductRead`
- `n8n-nodes-ndc.ndcFoundryBinding`
Their three opaque capability credential schemas are
`ndcDataProductWriterApi`, `ndcDataProductReaderApi` and
`ndcFoundryBindingApi`. A node description containing `usableAsTool` is
rejected because n8n 2.3.2 would synthesize an additional `*Tool` runtime type
and violate the exact-three activation contract. Run the positive and negative
release-policy suite before publishing an artifact:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
```
Release/rollback manifests use schema v2. Before a first activation, the
Engine-owned activator must verify and record the current inactive state. That
`verified_inactive` state is an allowed rollback baseline when no previous
verified immutable release exists; later upgrades prefer the previous verified
release. Rollback never deletes or mutates a staged release.
The historical `0.1.0` release remains immutable and must not be overwritten.
Release `0.1.1-994756958861518e` is retained as rejected/inactive: its three
node descriptions used `usableAsTool`, so n8n 2.3.2 exposed six NDC runtime
types instead of the required three. It must not be activated, overwritten or
deleted.
The active predecessor is package version `0.1.4` at immutable release
`0.1.4-59dc9f7882721d6a`. Package `0.1.5` adds the provider-neutral complete
snapshot replace mode used by `map.zones.current.v2`; its Platform staging
remains inert until a separately reviewed Engine-owned transition selects the
exact digest and accepts the updated MCP node schema.
The paired Engine activation is built by
`build-engine-n8n-private-extension-artifact.mjs`. It deliberately does not
copy from or write generated files into the dirty Engine worktree, and it does
not build an image. A fresh transition id is mandatory and previously issued
ids are rejected:
```bash
node infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs 20260721-005
```
The builder emits a narrowly scoped Compose override plus a strict transition
descriptor. On apply, the runner validates the staged release again, verifies
that the running n8n container and the NAS-local `2.3.2` tag resolve to the
same immutable image ID, and extracts the package into the root-owned,
read-only Engine release tree:
```text
/volume2/nodedc-demo/n8n-private-extensions/releases/n8n-nodes-ndc/0.1.5-3c8ae53f010d7c88/package
```
The override sets `N8N_USER_FOLDER=/home/node`, which is required because the
actual Engine service runs as root while the canonical community package path
is below `/home/node/.n8n`. The live runtime contract remains the successfully
deployed generation-003 contract and does not set `NODE_PATH`. Only the
runner's isolated `node -e` package-loader probe temporarily initializes the
dependency tree bundled inside the exact n8n base image; this reproduces n8n's
own loader without redefining the live Compose state. The override enables
loading but disables reinstall, mounts only the exact release read-only, and
uses both Compose `pull_policy: never` and `docker compose up --pull never`.
No registry access, lifecycle script, database `installed_packages` row or
custom-extension loader is involved.
The runner pins the exact Engine service topology observed in source and
rejects an added worker/webhook generation. Only the single actual `n8n`
service is force-recreated with `--no-deps`; the
Postgres service, `.n8n` data, encryption key and credentials remain intact.
The apply gate verifies readiness, the running image/version, sealed mount,
loader environment, package-loader node/credential sets, scoped loader logs,
restart stability and content-exact pinned Engine MCP catalogs. The runner pins
the complete 434/385 inactive baseline and a digest registry for every
reviewed 437/388 active release, so a same-count substitution of any built-in
or private schema is rejected. Upgrade `0.1.2 -> 0.1.3` is accepted only when
the verified live predecessor, descriptor, package mount and catalog all agree.
Any gate failure after
mutation automatically restores the pre-apply catalogs/descriptor and
force-recreates the previous verified runtime. Staged, sealed and failed
releases are retained. The paired rollback artifact returns `0.1.3` to the
verified immutable `0.1.2` release and its exact catalogs; it does not invent
an inactive baseline for an already-active upgrade.
Run both policy suites before publishing the Engine pair:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_engine_n8n_private_extension.py
```
The source-less Engine MCP control-plane update is a separate exact slice:
```bash
node infra/deploy-runner/build-engine-mcp-control-plane-artifact.mjs 20260717-001
```
Its six-entry registry contains only the Engine Agent gateway, verified graph
patch route, Codex installer source/package and the active node-intelligence
descriptor with the new gateway digest. It force-recreates only the existing
`nodedc-backend`. The node-intelligence image/service, n8n, L1, credentials,
databases and volumes are outside the slice. The runner proves the installed
predecessor digests, exact installer archive/source equality, bounded change
session policy, no-effect/post-write graph barriers, active immutable backend
runtime and descriptor equality. The ordinary overlay backup is the automatic
rollback source; rollback restores the predecessor gateway and descriptor
together before recreating the same backend service.
The successor autonomy/provider-v5 slice keeps MCP authority explicit without
turning every write into a second permission dialogue. MCP tool availability is
the capability boundary, while the user's current objective is the intent
boundary; Engine L2 may act autonomously only inside their intersection. A
graph `plan` remains a mandatory machine barrier whose target, diff, revision
and blockers are inspected by the agent. It is not a repeated approval prompt
after an implementation objective is already authorized. Retries must add new
evidence or change the attempted variant, and three identical failures without
new evidence or state change are a critical stop. The slice also advances the
installer to `0.1.6` and adds `gelios.provider.v5 ->
fleet.positions.current.v4` beside the immutable v4/v3 rollback line:
```bash
node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
20260720-004
```
Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases,
credential values and the node-intelligence image remain outside the change.
The runner accepts both the exact target and the exact predecessor after an
automatic rollback, and rejects every mixed state.
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
the frozen legacy Gelios compatibility service, and the provider-neutral
External Data Plane sources. The Gelios service remains reproducible only to
protect its existing database/workflow; it is not a template for a provider
integration. An External Data Plane artifact builds only its image and
force-recreates only `external-data-plane`; the already healthy
`external-data-plane-postgres` container and its Timescale volume are an
independent deploy prerequisite and are never selected by an EDP application
artifact. The reviewed Compose source pins the Timescale image, named volume
and target, internal database-only network, absence of database host ports,
healthy dependency, localhost-only EDP bind and the three read-only
provisioner/trust mounts in the reviewed Compose source. The runner does not
reinterpret version-dependent `docker compose config` JSON as a second deploy
schema. Its canonical enforcement remains the artifact/path allowlist plus
hard-coded build command, selected service set, runtime-secret preparation and
health acceptance. Post-apply acceptance also requires
`database=ready`; the managed Foundry slice additionally requires
`foundryReaderBindingProvisioning=digest+server-resolved-source`. A first-rollout failure removes only the candidate EDP
container without volumes and restores the source overlay. It never contains a
provider credential, provider endpoint,
collection schedule or command
transport. Its contract payload is an exact provider-neutral runtime subset;
`providers/*`, mappings, fixtures and tests are excluded and do not trigger an
EDP rebuild. Database credentials remain root-owned live `.env.synology`
configuration and must not reuse `NODEDC_INTERNAL_ACCESS_TOKEN`.
On the first relevant Platform apply, the root-owned runner creates
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`
atomically in a dedicated UID/GID `11006` directory (directory `0500`, token
`0400`). It is never an `.env` value or an artifact member and is mounted
read-only only into External Data Plane.
Manual one-time binding issuance and digest-only managed writer ensure are
independently disabled by default. The target control-plane operation generates
and stores the capability inside native NDC L2 Credentials and sends only its
digest to EDP; users and MCP consumers receive only an opaque compatible
reference/status. The runner-owned bearer above authenticates only legacy
plaintext issuance and is intentionally rejected by managed ensure/revoke.
Managed requests use a deployment Ed25519 Engine service key; EDP mounts only
the public-key trust directory read-only. On every reviewed Engine apply and on
an EDP runtime apply, this runner creates or validates one matching Ed25519 pair:
the Engine-only private key is `root:root 0400`, while the EDP trust copy is
`root:11006 0440`. Public-only crash state, key mismatch, a non-Ed25519 key,
symlinks and permissive modes fail closed. `plan` discloses both paths without
printing key material. The private key must not be broadened into an L2 graph,
MCP surface, artifact or shared-token boundary.
Module Foundry has a separate Ed25519 managed-provisioner identity for
target-scoped Data Product consumer grants. On a relevant `platform` or
`module-foundry` apply, the runner creates or validates the Foundry-only private
key at
`/volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem`
as `root:root 0400` and the matching EDP trust copy at
`/volume1/docker/nodedc-platform/trust/foundry-managed-provisioner/public-key.pem`
as `root:11006 0440`. Foundry generates the opaque reader token only inside its
persistent private runtime; its signed EDP request contains only a SHA-256
digest and Data Product id. EDP resolves the unique active writer source scope
server-side and fails closed on missing or ambiguous coverage. No provider,
tenant, connection, token, private key or endpoint is admitted to the Foundry
MCP plan, application state or browser response. The Engine signing identity,
native n8n credentials and legacy issuance bearer cannot call this endpoint.
The reviewed Engine source candidate has dedicated server-derived MCP
plan/apply handling for the exact `ndcDataProductWriterApi` + Data Product
Publish tuple. It is separate from the generic HTTP safe-ref path and accepts no
caller-provided provider/scope/credential identity, capability, generation or
service URL. The production managed flag remains false during staging. In the
explicitly confirmed activation window it is set to true immediately before
the Platform EDP artifact; runner acceptance then requires EDP `/healthz` to
report managed provisioning `enabled`. At that point the old Engine still has
no signer mount, so the endpoint remains usable only after the separately
accepted Engine artifact. Root/UI transfer is emergency
self-hosted diagnostics only, not the user journey or acceptance
path. A provider-specific daemon remains forbidden.
Build the narrow Engine source artifact with:
```bash
node infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs \
engine-data-product-publish-grant-YYYYMMDD-NNN
```
The builder includes exactly the pinned provider-security catalog, the
Publish-grant service, Engine Agent scope/gateway wiring, the existing n8n
adapter and the reviewed additive backend runtime overlay. It deliberately
excludes base Compose, frontend/dist, runtime data, tests, native credentials,
the NDC L2 process and the legacy generic credential-sink route/core. The
runner fixes the runtime action to `nodedc-backend` with `--no-deps` and
`--pull never`; n8n, nginx app, database services and volumes are not selected.
Acceptance requires backend health, the active immutable backend identity and
the exact additive mount inventory. Any failure restores the touched source and
recreates only the previous verified backend runtime.
Existing Engine Agents created before Publish-grant support store the original
nine scopes as the complete developer profile. They must not require a new
agent, setup command or device credential when the profile gains a server-owned
capability. Build the compatibility artifact that introduces the durable named
`full-developer` profile with:
```bash
node infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs \
engine-agent-full-grant-migration-YYYYMMDD-NNN
```
This follow-up slice contains exactly
`nodedc-source/server/engineAgents/store.js`. The runner pins its predecessor to
the successfully applied Publish generation, requires the installed Publish
overlay and active immutable backend, recreates only `nodedc-backend`, and
proves the exact candidate SHA, named profile and current expanded runtime
scope view. Store schema v1 is migrated atomically to v2 only when a grant
contains the complete legacy nine-scope developer bundle. From then on the
profile name is the authorization authority and its scope list is derived on
every read, so capabilities deliberately added to `full-developer` immediately
apply to existing full grants without token or store migrations. Partial grants
become `custom` and remain exact; they are never elevated. The artifact does not
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
payloads.
## Engine L2 node-intelligence transition
The node-intelligence transition is an additive Engine-owned deployment domain.
It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the
reviewed upstream `n8n-mcp` implementation at package `2.33.2`, commit
`974a9fb3492fe2c4984ee0549085d531cdc6242a`, and exposes only the safe NDC L2
projection through the existing Engine Agent gateway. Upstream management and
write tools are not forwarded.
Production never clones, pulls, installs or builds this dependency. The builder
saves the reviewed `linux/amd64` image once, embeds that exact archive in a
data-only activation artifact and records both the archive and image-config
digests. The runner validates every inner blob, revision label, entrypoint,
command and platform before an offline `docker image load`; Compose then uses
the fixed tag with `pull_policy: never` and `--pull never`.
Build a fresh activation/rollback pair:
```bash
node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
YYYYMMDD-NNN
```
The activation exact set is:
- `nodedc-source/server/nodeIntelligence`
- `nodedc-source/server/routes/engineAgentGateway.js`
- `nodedc-source/services/node-intelligence`
The runtime adds only `nodedc-node-intelligence` and recreates the existing
`nodedc-backend`; n8n, UI, databases, L1, provider services and volumes are not
selected. The sidecar has no host port, runs as `11007:11007`, has a read-only
root filesystem, drops all capabilities and receives no Engine or provider
credential. Its independent MCP bearer is created by the root-owned runner as a
read-only file and mounted only into the sidecar and backend. It never appears
in an artifact, shared environment file, log or MCP response.
Acceptance requires the exact image/container/mount/security inventory,
immutable backend identity and live authenticated `get_node`, `validate_node`
and `validate_workflow` calls. Any apply failure restores source and runtime
automatically. The separate rollback artifact first removes only the sidecar
without volumes, restores the pinned inactive gateway, and recreates only the
verified backend. Loaded images and failed candidate source are retained for
audit rather than destructively deleted.
Stage the reviewed runner under a unique candidate name first:
```text
/volume1/docker/nodedc-deploy/runner-install/candidates/
nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN
```
Do not overwrite the canonical staging candidate while another deploy may be in
flight. After the no-lock/no-process gate, promote the exact candidate in a
standalone root step, run `verify-install`, then invoke a fresh process for
activation `plan` and only then `apply`. The generated plan/apply runbook carries
the runner, activation and rollback SHA-256 values and is staged beside the
unique runner candidate.
`module-foundry` is an independent, authenticated application component. Its
artifact contains source and compose infrastructure only; its live
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and
never enters an artifact. The component reuses the existing internal platform
credential for Launcher handoff validation and requires that runtime
configuration before its first `apply`.
When the managed reader-grant source is present, Module Foundry acceptance also
requires `/healthz` to report the dedicated Ed25519 provisioner as configured.
The check is source-aware: if an apply rolls back to the prior source, rollback
acceptance uses that prior health contract instead of falsely requiring a
feature which the restored generation does not contain.
The Foundry ↔ Map Gateway signing key is not an application or `.env` setting.
On the first relevant `platform` or `module-foundry` apply, the root-owned
runner creates `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`
atomically (root:gid 1000, mode `0640`). Both containers receive that file only
as a read-only mount. The value is never printed, backed up with source,
included in an artifact, or administered through Foundry.
`proxy-contur` is the canonical VPN egress for selected Map Gateway provider
hosts. Its existing root-owned `PROXY_TOKEN` is copied by the runner into
`/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token` with
`root:gid 1000`, mode `0640`, then mounted read-only only into Map Gateway.
The value is neither printed nor contained in an artifact, Foundry setting, or
browser response. Apply the `proxy-contur` artifact before the Platform Map
Gateway artifact: it creates the private `nodedc-map-egress` Docker network.
`dc-amd-proxy` is the separate, staged connector for the neighbouring AMD VPN
machine. Its active artifact attaches only to the private `nodedc-map-egress`
network, exposes a narrow NAS-LAN pairing port, and has no direct provider
egress. The runner preserves a `0700`, service-user-owned runtime directory
for the one-time paired connector credential and synchronizes the existing
private Map Gateway egress credential as a read-only file. Neither value is
ever placed in an artifact, `.env`, browser response, or runner output. The
separate Platform switch is applied only after the connector and pairing are
verified; it does not alter NAS routes, VPN, DNS, or Tailscale.
Install or update the root-owned live runner on Synology:
```bash
# First verify that no deploy process is active and state/deploy.lock is absent.
sudo install -o root -g root -m 0755 \
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy \
/usr/local/sbin/nodedc-deploy
sudo /usr/local/sbin/nodedc-deploy verify-install
```
Runner promotion is a standalone admin step, never an app-overlay artifact and
never part of a running apply. A Python process already executing the old file
keeps the old code in memory; always invoke a fresh verified process afterward.
Normal service deploys must still use explicit artifacts:
```bash
sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
```
## Device Plane foundation
`device-plane` is an additive component rooted at:
```text
/volume1/docker/nodedc-device-plane
```
Its fixed Compose project is `nodedc-device-plane`. Ordinary application
artifacts may select only `device-control-core` and `device-gateway`, always
with `--no-deps`. `device-postgres` and the named
`nodedc-device-plane-postgres-data` volume are durable prerequisites and are
never selected or recreated by an application overlay.
The sole exception is the exact one-time bootstrap artifact containing only
the reviewed Compose file and
`deployment/device-postgres-bootstrap-v1.json`. Its preflight requires both the
Compose database container and named volume to be absent. It selects only
`device-postgres`; a failed activation may remove that candidate container but
never the volume. Any pre-existing container or volume is an ambiguity and
fails closed.
The runner creates or validates three root-owned secret files outside the
artifact: the PostgreSQL password, Gateway-to-Core token and restricted
identifier pepper. The manifest cannot choose their paths or values.
The foundation publishes only loopback health endpoints on `18120` and
`18121`. Raw device ingress `9921`, discovery ingest and outbound command
transport remain disabled. The first application artifact must not be built or
staged until this runner candidate is separately reviewed, promoted and proven
by a fresh `verify-install`.
Build and test the deterministic data-only artifact contract locally:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_registry \
infra.deploy-runner.test_device_plane_artifact
node infra/deploy-runner/build-device-plane-artifact.mjs \
device-plane-foundation-YYYYMMDD-NNN
```
Any failed first activation removes only candidate Core/Gateway containers,
never volumes, restores the source overlay and retains PostgreSQL state.
The rollback baseline is taken from an explicit pre-apply Docker service
inventory stored as `runtime-before.json` in the backup. The presence of the
shared Compose file never implies that Core or Gateway existed before apply.
The exact `device-plane-foundation-20260725-001` failed activation is recovered
only through the registered
`deployment/device-plane-foundation-recovery-v1.json` transition. Its artifact
must reproduce the failed foundation source byte-for-byte. The runner validates
the exact failed archive, journal, backup partition, partial live source and
healthy observed Core/Gateway/PostgreSQL generations. Recovery publishes source
and performs read-only runtime acceptance; it does not build, recreate, restart
or remove containers. A failed recovery restores source only and leaves runtime
unchanged.
Build and test that incident-specific deterministic recovery artifact:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_foundation_recovery_artifact
node infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs \
device-plane-foundation-recovery-20260725-002
```
The recovery `device-plane-foundation-recovery-20260725-002` is also terminal
failed. Docker Engine 24 does not materialize requested host port mappings when
the container is attached only to an `internal` network. Its registered
successor is the exact
`deployment/device-plane-foundation-network-publication-v1.json` transition.
It keeps PostgreSQL exclusively on the existing internal private network and
adds a second non-internal bridge only to Control Core and Gateway. Masquerade
is disabled on that bridge; the only published ports remain
`127.0.0.1:18120` and `127.0.0.1:18121`. Device TCP `9921`, public ingress,
discovery ingest and command transport remain disabled.
This successor has an empty build set and force-recreates only
`device-control-core` and `device-gateway` with `--no-deps` from their exact
existing `pull_policy: never` images. Its predecessor barrier validates the
terminal failed archive/journal/backup, partial source, exact running
generations, absent actual port mappings, internal private network and absence
of the control network. Acceptance requires new stateless container
generations, the unchanged PostgreSQL generation and volume, exact actual
loopback mappings, healthy HTTP contracts and a closed `9921`.
Rollback removes only the two stateless candidate containers and the newly
created unused control network, restores the partial source predecessor and
recreates the internal-only Core/Gateway runtime from the sealed predecessor
Compose bytes. PostgreSQL and its volume are never selected.
Build and test the deterministic network-publication successor:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_foundation_network_publication_artifact
node \
infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs \
device-plane-foundation-network-publication-20260725-003
```
After the runner is promoted and freshly verified, bootstrap the durable
prerequisite with a separate artifact before planning the application:
```bash
node infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs \
device-plane-postgres-bootstrap-YYYYMMDD-NNN
```
@@ -0,0 +1,129 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const bimRoot = resolve(process.env.NODEDC_BIM_VIEWER_ROOT || resolve(platformRoot, "../NODEDC_BIM_VIEWER"));
const taskerRoot = resolve(
process.env.NODEDC_TASKMANAGER_ROOT || resolve(platformRoot, "../../data/dc_taskmanager/NODEDC_TASKMANAGER"),
);
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [release = "20260730-002", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-\d{3}$/.test(release)) {
throw new Error("usage: build-bim-tasker-cad-ops-artifacts.mjs [YYYYMMDD-NNN]");
}
const descriptors = [
{
artifactBasename: `nodedc-bim-viewer-cad-ops-${release}.tgz`,
component: "bim-viewer",
files: [
"converter/worker.py",
"frontend/dcViewer.js",
"server/cad-formats.js",
"server/index.js",
"server/tasker-gateway-auth.js",
],
patchId: `bim-viewer-cad-ops-${release}`,
sourceRoot: bimRoot,
},
{
artifactBasename: `nodedc-tasker-cad-ops-${release}.tgz`,
component: "tasker",
files: [
"plane-src/apps/api/plane/app/urls/issue.py",
"plane-src/apps/api/plane/app/views/__init__.py",
"plane-src/apps/api/plane/app/views/issue/attachment.py",
"plane-src/apps/api/plane/app/views/issue/bim_attachment.py",
"plane-src/apps/api/plane/utils/nodedc_bim_gateway.py",
"plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx",
"plane-src/apps/web/core/services/issue/issue_attachment.service.ts",
"plane-src/apps/web/helpers/beam-viewer-config.ts",
"plane-src/apps/web/helpers/beam-viewer.ts",
],
patchId: `tasker-cad-ops-${release}`,
sourceRoot: taskerRoot,
},
];
await mkdir(artifactDir, { recursive: true });
const artifacts = [];
for (const descriptor of descriptors) {
const sourceCommit = gitOutput(descriptor.sourceRoot, ["rev-parse", "HEAD"]);
const sourceStatus = gitOutput(descriptor.sourceRoot, ["status", "--porcelain"]);
if (sourceStatus) throw new Error(`source_worktree_not_clean:${descriptor.component}`);
const stage = await mkdtemp(join(tmpdir(), `nodedc-${descriptor.component}-cad-ops-`));
const payload = join(stage, "payload");
const artifact = join(artifactDir, descriptor.artifactBasename);
try {
await mkdir(payload, { recursive: true });
for (const relativePath of descriptor.files) {
const source = resolve(descriptor.sourceRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${descriptor.component}:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${descriptor.patchId}\ncomponent=${descriptor.component}\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${descriptor.files.join("\n")}\n`, "utf8");
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${descriptor.component}:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
artifacts.push({
artifact,
component: descriptor.component,
files: descriptor.files,
patchId: descriptor.patchId,
sha256,
sourceCommit,
});
} finally {
await rm(stage, { recursive: true, force: true });
}
}
console.log(JSON.stringify({ ok: true, release, artifacts }, null, 2));
function gitOutput(cwd, args) {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) throw new Error(`git_failed:${cwd}:${args.join("_")}:${result.stderr || result.stdout}`);
return result.stdout.trim();
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const [patchId = "dc-amd-proxy-bootstrap-20260715-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-dc-amd-proxy-artifact.mjs [patch-id]");
const files = [
["services/dc-amd-proxy/Dockerfile", "Dockerfile"],
["services/dc-amd-proxy/README.md", "README.md"],
["services/dc-amd-proxy/docker-compose.yml", "docker-compose.yml"],
["services/dc-amd-proxy/package.json", "package.json"],
["services/dc-amd-proxy/server.mjs", "server.mjs"],
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-dc-amd-proxy-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=dc-amd-proxy\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const info = await lstat(source);
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}
@@ -0,0 +1,265 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-edge-admission-gate-20260804-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-edge-ingress-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
"services/device-edge-relay/Dockerfile",
"services/device-edge-relay/src",
"deployment/device-edge-admission-gate-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const descriptor = await assertBoundary();
if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") {
throw new Error("device_edge_ingress_ipv4_approval_pending");
}
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-edge",
transition: "reviewed-ipvlan-b2-relay-admission-gate",
entries: files,
services: ["device-edge-relay"],
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
ingress: {
parent: descriptor.parentInterface,
subnet: descriptor.lanSubnet,
gateway: descriptor.lanGateway,
ipv4: descriptor.ingressIpv4,
ipv4Approval: descriptor.ingressIpv4Approval,
tcp: 9921,
hostPortPublication: "disabled",
sourceAdmission: descriptor.sourceAdmission,
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
maxBytesPerDirection: descriptor.maxBytesPerDirection,
lifecycle: "quarantine",
commandTransport: "disabled",
},
rollback: descriptor.rollback,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const baseline = await readFile(
resolve(sourceRoot, "docker-compose.device-edge.yml"),
"utf8",
);
const ingress = await readFile(
resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-edge-admission-gate-v1.json",
),
"utf8",
));
for (const fragment of [
"DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1",
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"',
"read_only: true",
'user: "1000:1000"',
"no-new-privileges:true",
"cap_drop:",
"- ALL",
]) {
if (!baseline.includes(fragment)) {
throw new Error(`device_edge_baseline_boundary_missing:${fragment}`);
}
}
for (const forbidden of ["ports:", "device-edge-control"]){
if (baseline.includes(forbidden)) {
throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`);
}
}
for (const fragment of [
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"ipvlan_mode: l2",
"ipv4_address: 192.168.71.253",
"gw_priority: 100",
"subnet: 192.168.68.0/22",
"gateway: 192.168.68.1",
]) {
if (!ingress.includes(fragment)) {
throw new Error(`device_edge_ingress_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"ports:",
"network_mode: host",
"privileged: true",
"DEVICE_EDGE_RELAY_COMMAND",
"0.0.0.0:9921:9921",
]) {
if (ingress.includes(forbidden)) {
throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`);
}
}
const expected = {
schemaVersion: "nodedc.device-edge.admission-gate.v1",
mode: "single-nic-ipvlan-b2-relay-only",
runtimeHost: "ndcmini12",
component: "device-edge",
selectedServices: ["device-edge-relay"],
preservedServices: ["device-edge-backhaul", "tailnet"],
composeProject: "nodedc-device-edge",
composeFiles: [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
],
parentInterface: "enp1s0f0",
lanSubnet: "192.168.68.0/22",
lanGateway: "192.168.68.1",
ingressIpv4: "192.168.71.253",
ingressIpv4Approval: "approved-outside-dhcp-pool",
ingressNetwork: "nodedc-device-edge-ingress",
deviceTcpListen: "192.168.71.253:9921",
hostPortPublication: "disabled",
healthPublication: "disabled",
privateUpstream: "device-edge-backhaul:19921",
sourceAdmission: "public-ipv4-only",
maxTrackedSourceAddresses: 2048,
maxBytesPerDirection: 262144,
protocolInspection: "gateway-owned",
identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine",
commandTransport: "disabled",
gelios: "untouched",
amneziaHostFullTunnel: "preserved",
routerNatFirewall: "separate-manual-gate",
rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_edge_ingress_descriptor_mismatch");
}
return descriptor;
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,201 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const failedFoundationCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-internal-only-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "device-plane-foundation-20260725-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-device-plane-artifact.mjs [patch-id]");
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
];
const ignoredBasenames = new Set([
".DS_Store",
".git",
"node_modules",
]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? failedFoundationCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
entries: files,
services: ["device-control-core", "device-gateway"],
preserved: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const composeSource = failedFoundationCompose;
const compose = await readFile(
composeSource,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"source: /volume1/docker/nodedc-device-plane/secrets/postgres-password",
"create_host_path: false",
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(`device_plane_compose_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
"DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"",
"DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"",
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(`device_plane_compose_boundary_violation:${forbidden}`);
}
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, source)}`,
);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| (entry.isDirectory() && ignoredDirectoryNames.has(entry.name))
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,271 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-b2-discovery-loopback-20260726-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-b2-discovery-ingress-artifact.mjs "
+ "[patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"services/device-edge-relay/package.json",
"deployment/device-plane-b2-discovery-ingress-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(
join(stage, "files.txt"),
`${files.join("\n")}\n`,
"utf8",
);
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "verified-b2-loopback-discovery-only",
entries: files,
services: ["device-control-core", "device-gateway"],
preservedRuntime: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
ingress: {
transport: "tcp",
published: "127.0.0.1:9921:9921",
mode: "loopback-discovery-only",
framing: "verified-read-only",
lifecycle: "quarantine",
commandTransport: "disabled",
},
rollback: "restore-source-and-predecessor-stateless-runtime",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.yml"),
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
'"127.0.0.1:9921:9921"',
"name: nodedc-device-plane-private",
"internal: true",
"name: nodedc-device-plane-control",
"internal: false",
'com.docker.network.bridge.enable_ip_masquerade: "false"',
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_b2_ingress_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"POSTGRES_PASSWORD:",
"DEVICE_GATEWAY_CORE_TOKEN:",
"DEVICE_IDENTIFIER_PEPPER:",
"DEVICE_GATEWAY_COMMAND",
"9921:9921/udp",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-b2-discovery-ingress-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
mode: "verified-b2-loopback-discovery-only",
predecessorPatchId:
"device-plane-foundation-network-publication-20260725-003",
predecessorArtifactSha256:
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
sourceAction: "publish-verified-b2-loopback-discovery-source",
runtimeAction: "build-and-recreate-stateless-services",
selectedServices: ["device-control-core", "device-gateway"],
preservedServices: ["device-postgres"],
privateNetwork: "nodedc-device-plane-private",
controlNetwork: "nodedc-device-plane-control",
publishedPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
"127.0.0.1:9921:9921/tcp",
],
protocolProfile: "arusnavi.b2.internal.v1",
framingSpecification:
"arusnavi.internal.protocol-sheet.gid-12.v1",
identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine",
commandTransport: "disabled",
gelios: "untouched",
databaseVolume: "nodedc-device-plane-postgres-data",
rollback: "restore-source-and-predecessor-stateless-runtime",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_b2_ingress_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, source)}`,
);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
force: true,
verbatimSymlinks: true,
});
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| (
entry.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,175 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const descriptorRelative =
"deployment/device-plane-b2-discovery-loopback-recovery-v1.json";
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: "
+ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs "
+ "[patch-id]",
);
}
const files = [descriptorRelative];
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertRecoveryDescriptor();
try {
const source = resolve(sourceRoot, descriptorRelative);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error("device_plane_b2_recovery_descriptor_unsafe");
}
await mkdir(dirname(join(payload, descriptorRelative)), {
recursive: true,
});
await cp(source, join(payload, descriptorRelative), {
force: true,
verbatimSymlinks: true,
});
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(
join(stage, "files.txt"),
`${files.join("\n")}\n`,
"utf8",
);
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-b2-loopback-build-reconciliation",
entries: files,
build: [],
services: [],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
sourceAction: "publish-reconciliation-marker-only",
runtimeAction: "read-only-acceptance",
ingress: "disabled:127.0.0.1:9921/tcp:closed",
rollback: "marker-only-runtime-unchanged",
excluded: [
"application-source",
"compose",
"Dockerfile",
"secrets",
"runtime",
"database",
"Gelios",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertRecoveryDescriptor() {
const descriptor = JSON.parse(await readFile(
resolve(sourceRoot, descriptorRelative),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
mode: "failed-b2-loopback-build-reconciliation",
failedPatchId: "device-plane-b2-discovery-loopback-20260801-003",
failedArtifactSha256:
"7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
failedBackupId:
"device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
sourceAction: "publish-reconciliation-marker-only",
runtimeAction: "read-only-acceptance",
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
expectedLoopbackPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
closedPort: "127.0.0.1:9921/tcp",
databaseVolume: "nodedc-device-plane-postgres-data",
commandTransport: "disabled",
gelios: "untouched",
rollback: "marker-only-runtime-unchanged",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_b2_recovery_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,256 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-plane.backhaul-target.yml",
"services/device-backhaul-target",
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
entries: files,
services: ["device-backhaul-target"],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
ingress: {
loopbackListen: "127.0.0.1:2222/tcp",
tailnetListen: "100.109.216.21:2222/tcp",
transport: "tailscale-serve-private-ssh",
serveTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
},
runtimeTrust: "runner-managed-not-in-artifact",
rollback: "remove-tailnet-serve-target-and-restore-source",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
"utf8",
);
for (const fragment of [
"device-backhaul-target:",
"image: nodedc/device-backhaul-target:local",
"network_mode: host",
'"127.0.0.1", "2222"',
"/secrets/backhaul-target/ssh_host_ed25519_key",
"/secrets/backhaul-target/authorized_keys",
"no-new-privileges:true",
]) {
if (!compose.includes(fragment)) {
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"PasswordAuthentication yes",
"0.0.0.0:2222",
"9921:9921/udp",
"DEVICE_GATEWAY_COMMAND",
]) {
if (compose.includes(forbidden)) {
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
}
}
const sshd = await readFile(
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
"utf8",
);
for (const fragment of [
"ListenAddress 127.0.0.1",
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"AllowTcpForwarding local",
"PermitOpen 127.0.0.1:9921",
"GatewayPorts no",
"PermitTunnel no",
"AllowAgentForwarding no",
"PermitTTY no",
"ForceCommand /bin/false",
]) {
if (!sshd.includes(fragment)) {
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
failedPatchId: "device-plane-backhaul-target-20260803-001",
failedArtifactSha256:
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
failedBackupId:
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
predecessorPatchId:
"device-plane-b2-discovery-loopback-20260803-006",
predecessorArtifactSha256:
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
sourceAction: "publish-loopback-backhaul-target-source",
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
selectedServices: ["device-backhaul-target"],
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
loopbackListenAddress: "127.0.0.1",
listenPort: 2222,
tailnetAddress: "100.109.216.21",
tailnetExposure: "tailscale-serve-private",
tailscaleServeTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
networkMode: "host",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
gelios: "untouched",
databaseVolume: "nodedc-device-plane-postgres-data",
runtimeTrust: "runner-managed",
rollback: "remove-tailnet-serve-target-and-restore-source",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_backhaul_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| entry.name.endsWith("~")
) {
continue;
}
await copySafe(join(source, entry.name), join(destination, entry.name));
}
}
@@ -0,0 +1,274 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const networkPublicationCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-network-publication-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-foundation-network-publication-20260725-003",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-foundation-network-publication-artifact.mjs "
+ "[patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"deployment/device-plane-foundation-network-publication-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-network-publication-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? networkPublicationCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(
join(stage, "files.txt"),
`${files.join("\n")}\n`,
"utf8",
);
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-foundation-network-publication-correction",
entries: files,
build: [],
services: ["device-control-core", "device-gateway"],
preservedRuntime: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
networkChange: {
private: "preserved:internal",
control: "create:non-internal:no-masquerade",
published: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
disabled: ["9921", "public-ingress", "command-transport"],
},
rollback:
"restore-partial-source-and-internal-only-stateless-runtime",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
networkPublicationCompose,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"name: nodedc-device-plane-private",
"internal: true",
"name: nodedc-device-plane-control",
"internal: false",
'com.docker.network.bridge.enable_ip_masquerade: "false"',
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_network_publication_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_network_publication_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-foundation-network-publication-v1.json",
),
"utf8",
));
const expected = {
schemaVersion:
"nodedc.device-plane.foundation-network-publication.v1",
mode: "failed-foundation-network-publication-correction",
failedRecoveryPatchId:
"device-plane-foundation-recovery-20260725-002",
failedRecoveryArtifactSha256:
"9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
failedRecoveryBackupId:
"device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
sourceAction: "publish-network-corrected-foundation-source",
runtimeAction: "recreate-stateless-services-no-build",
selectedServices: ["device-control-core", "device-gateway"],
preservedServices: ["device-postgres"],
privateNetwork: "nodedc-device-plane-private",
controlNetwork: "nodedc-device-plane-control",
publishedLoopbackPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
databaseVolume: "nodedc-device-plane-postgres-data",
rollback:
"restore-partial-source-and-internal-only-stateless-runtime",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error(
"device_plane_network_publication_descriptor_mismatch",
);
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, source)}`,
);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
force: true,
verbatimSymlinks: true,
});
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| (
entry.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,255 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const predecessorCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-internal-only-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-foundation-recovery-20260725-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"deployment/device-plane-foundation-recovery-v1.json",
];
const ignoredBasenames = new Set([
".DS_Store",
".git",
"node_modules",
]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-foundation-recovery-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertRecoveryBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? predecessorCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(
join(stage, "files.txt"),
`${files.join("\n")}\n`,
"utf8",
);
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-foundation-live-runtime-adoption",
entries: files,
build: [],
services: [],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
],
sourceAction: "publish-exact-failed-artifact-source",
runtimeAction: "read-only-acceptance",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertRecoveryBoundary() {
const compose = await readFile(
predecessorCompose,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"name: nodedc-device-plane-postgres-data",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_recovery_compose_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_recovery_compose_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-foundation-recovery-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.foundation-recovery.v1",
mode: "failed-foundation-live-runtime-adoption",
failedPatchId: "device-plane-foundation-20260725-001",
failedArtifactSha256:
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
backupId:
"device-plane-device-plane-foundation-20260725-001-20260725-223441",
sourceAction: "publish-exact-failed-artifact-source",
runtimeAction: "read-only-acceptance",
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
databaseVolume: "nodedc-device-plane-postgres-data",
rollback: "source-only-runtime-unchanged",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_recovery_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, source)}`,
);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
force: true,
verbatimSymlinks: true,
});
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| (
entry.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-plane.yml",
"deployment/device-postgres-bootstrap-v1.json",
];
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const relativePath of files) {
const source = resolve(sourceRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`bootstrap_source_file_required:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
force: true,
verbatimSymlinks: false,
});
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256,
component: "device-plane",
entries: files,
services: ["device-postgres"],
mode: "create-if-absent",
rollbackVolumePolicy: "preserve",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const descriptor = JSON.parse(
await readFile(
resolve(
sourceRoot,
"deployment/device-postgres-bootstrap-v1.json",
),
"utf8",
),
);
const expected = {
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
service: "device-postgres",
volume: "nodedc-device-plane-postgres-data",
mode: "create-if-absent",
ordinaryApplicationSelection: "forbidden",
rollbackVolumePolicy: "preserve",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
}
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.yml"),
"utf8",
);
for (const required of [
"device-postgres:",
"name: nodedc-device-plane-postgres-data",
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
"create_host_path: false",
]) {
if (!compose.includes(required)) {
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
}
}
const postgresStart = compose.indexOf(" device-postgres:");
const postgresEnd = compose.indexOf("\n device-control-core:");
if (
postgresStart < 0
|| postgresEnd <= postgresStart
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
) {
throw new Error("device_plane_postgres_host_port_forbidden");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,166 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const [patchId = '', ...extra] = process.argv.slice(2)
const storeRelativePath = 'nodedc-source/server/engineAgents/store.js'
const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a'
const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9'
const readerExtendedSourceSha256 = 'debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f'
const ontologyExtendedSourceSha256 = '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'
const frozenTargetArtifact = join(
workspaceRoot,
'deploy-artifacts/nodedc-engine-agent-full-grant-migration-20260717-002.tgz',
)
const frozenTargetArtifactSha256 = 'd104ee6e63d1fccb9069e2b3db5e0446907ee9bf9cb04f2387636c272f803d68'
const previouslyIssuedPatchIds = new Set([
'engine-agent-full-grant-migration-20260717-001',
])
if (extra.length || !/^engine-agent-full-grant-migration-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-agent-full-grant-migration-artifact.mjs <fresh-patch-id>')
}
if (previouslyIssuedPatchIds.has(patchId)) {
throw new Error('engine_agent_full_grant_migration_patch_id_already_issued')
}
const source = join(engineRoot, storeRelativePath)
const sourceInfo = await lstat(source)
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) throw new Error('engine_agent_store_source_unsafe')
const sourceBytes = await materializeFrozenTarget(await readFile(source))
if (digest(sourceBytes) !== targetSha256) throw new Error('engine_agent_store_target_sha256_mismatch')
const sourceText = sourceBytes.toString('utf8')
for (const required of [
'const STORE_VERSION = 2',
"export const ENGINE_AGENT_FULL_DEVELOPER_PROFILE = 'full-developer'",
"export const ENGINE_AGENT_CUSTOM_PROFILE = 'custom'",
'const LEGACY_FULL_DEVELOPER_SCOPES = Object.freeze([',
"'engine:l2:data-product-publish-grant:plan'",
"'engine:l2:data-product-publish-grant:write'",
'const migrateLegacyFullDeveloper = sourceVersion === 1',
'LEGACY_FULL_DEVELOPER_SCOPES.every((scope) => scopes.includes(scope))',
'profile === ENGINE_AGENT_FULL_DEVELOPER_PROFILE ? [...ENGINE_AGENT_SCOPES] : scopes',
"throw new Error('engine_agent_store_version_unsupported')",
]) {
if (!sourceText.includes(required)) throw new Error(`engine_agent_store_contract_missing:${required}`)
}
if (/gelios|robot2b/i.test(sourceText)) throw new Error('engine_agent_store_provider_logic_forbidden')
run('node', ['--check', source])
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-agent-grant-migration-'))
try {
const destination = join(stage, 'payload', storeRelativePath)
await mkdir(dirname(destination), { recursive: true })
await writeFile(destination, sourceBytes)
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${storeRelativePath}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
const artifactBytes = await readFile(artifact)
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(artifactBytes),
entries: [storeRelativePath],
predecessorSha256,
targetSha256,
services: ['nodedc-backend'],
excluded: ['n8n', 'app', 'databases', 'credentials', 'runtime-data'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_agent_full_grant_migration_artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip, io, pathlib, sys, tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
async function materializeFrozenTarget(bytes) {
const sourceSha256 = digest(bytes)
if (sourceSha256 === targetSha256) return bytes
if (sourceSha256 === ontologyExtendedSourceSha256) {
const artifactBytes = await readFile(frozenTargetArtifact)
if (digest(artifactBytes) !== frozenTargetArtifactSha256) {
throw new Error('engine_agent_store_frozen_target_artifact_sha256_mismatch')
}
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1])',
"with tarfile.open(p,'r:gz') as t:",
" f=t.extractfile('payload/nodedc-source/server/engineAgents/store.js')",
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
const frozen = Buffer.from(run('python3', ['-c', script, frozenTargetArtifact]).stdout, 'utf8')
if (digest(frozen) !== targetSha256) {
throw new Error('engine_agent_store_frozen_target_sha256_mismatch')
}
return frozen
}
if (sourceSha256 !== readerExtendedSourceSha256) {
throw new Error('engine_agent_store_target_sha256_mismatch')
}
const text = bytes.toString('utf8')
const readerScopes = [
" 'engine:l2:data-product-read-grant:plan',\n",
" 'engine:l2:data-product-read-grant:write',\n",
]
let frozen = text
for (const scope of readerScopes) {
if (!frozen.includes(scope)) throw new Error('engine_agent_store_reader_scope_boundary_missing')
frozen = frozen.replace(scope, '')
}
return Buffer.from(frozen, 'utf8')
}
@@ -0,0 +1,154 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const predecessorArtifact = resolve(
process.env.NODEDC_ENGINE_AUTH_RECOVERY_PREDECESSOR
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-restart-safe-auth-20260719-001.tgz',
)
const [patchId = '', ...extra] = process.argv.slice(2)
if (extra.length || !/^engine-auth-redirect-recovery-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-auth-redirect-recovery-artifact.mjs engine-auth-redirect-recovery-YYYYMMDD-NNN')
}
const predecessorArtifactSha256 = 'f6830046d7bd2bc32c1dbda8dce7ddf68d15c1e728f594c0f64ee8feec3911b8'
const predecessorManifest = 'id=engine-restart-safe-auth-20260719-001\ncomponent=engine\ntype=app-overlay\n'
const predecessorSha256 = Object.freeze({
'nodedc-source/src/platform/auth/access.ts': '9610dc1622a1c9b383eb3a1569347098002aa058dbbcbb23f096f2622d452222',
'nodedc-source/src/platform/auth/sessionRecovery.ts': 'f931cbb45d64d5d6a2868194e8c73879a408dffca1fdd7e41acfa262441c50d7',
'nodedc-source/dist/index.html': 'b031f6720683f6fb5ccfa59a01414ff2a3efcdf548b1c6aab33e1748ee3f003d',
})
const entries = Object.freeze([
'nodedc-source/src/platform/auth/access.ts',
'nodedc-source/src/platform/auth/sessionRecovery.ts',
'nodedc-source/dist/index.html',
'nodedc-source/dist/assets/index-DJ1CMfu4.js',
])
const candidateSha256 = Object.freeze({
'nodedc-source/src/platform/auth/access.ts': 'c686db2568912a093ea8934e34889254ddd659594f7084025c280732ef627002',
'nodedc-source/src/platform/auth/sessionRecovery.ts': '63f3e2379628dea3b119c4283bc0c3bb10e94199c673b9d9c579bac3c54be98f',
'nodedc-source/dist/index.html': '8894f62ad590168862e81266bc4602410279634b666af72cb14ad80aeb71ea74',
'nodedc-source/dist/assets/index-DJ1CMfu4.js': 'a0aaf72d2ed390142ff2ea03dbcfdd534637e5faefd80d8aa7d705a804abe065',
})
const predecessorInfo = await lstat(predecessorArtifact)
if (predecessorInfo.isSymbolicLink() || !predecessorInfo.isFile()) {
throw new Error('engine_auth_recovery_predecessor_unsafe')
}
if (digest(await readFile(predecessorArtifact)) !== predecessorArtifactSha256) {
throw new Error('engine_auth_recovery_predecessor_archive_mismatch')
}
run('python3', [
'-c', verifyPredecessorScript(), predecessorArtifact,
predecessorArtifactSha256, predecessorManifest, JSON.stringify(predecessorSha256),
])
for (const rel of entries) {
const source = resolve(engineRoot, rel)
const info = await lstat(source)
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`engine_auth_recovery_candidate_unsafe:${rel}`)
if (digest(await readFile(source)) !== candidateSha256[rel]) {
throw new Error(`engine_auth_recovery_candidate_sha256_mismatch:${rel}`)
}
}
const indexHtml = await readFile(resolve(engineRoot, 'nodedc-source/dist/index.html'), 'utf8')
if (!indexHtml.includes('/assets/index-DJ1CMfu4.js') || !indexHtml.includes('/assets/index-Bim2pv1P.css')) {
throw new Error('engine_auth_recovery_dist_entrypoint_mismatch')
}
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-auth-redirect-recovery-'))
try {
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
for (const rel of entries) {
const target = join(stage, 'payload', rel)
await mkdir(dirname(target), { recursive: true })
await copyFile(resolve(engineRoot, rel), target)
}
run('python3', ['-c', canonicalTarScript(), artifact, stage])
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
predecessorArtifact,
predecessorArtifactSha256,
predecessorSha256,
candidateSha256,
entries,
runtimeServices: ['nodedc-backend', 'app'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
function verifyPredecessorScript() {
return [
'import hashlib,json,pathlib,sys,tarfile',
'source=pathlib.Path(sys.argv[1]); expected_archive=sys.argv[2]',
'expected_manifest=sys.argv[3].encode(); hashes=json.loads(sys.argv[4])',
"assert hashlib.sha256(source.read_bytes()).hexdigest()==expected_archive, 'archive-sha256'",
"with tarfile.open(source,'r:gz') as tar:",
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'manifest'",
' for rel,wanted in hashes.items():',
" data=tar.extractfile('payload/'+rel).read()",
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload:'+rel",
].join('\n')
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_auth_recovery_artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
@@ -0,0 +1,164 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-composite-provider-v4-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-composite-provider-v4-artifact.mjs " +
"<engine-composite-provider-v4-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
"nodedc-source/server/dataProductPublishGrant/service.js":
"83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
"nodedc-source/server/dataProductPublishGrant/store.js":
"98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-composite-provider-v4-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const source = join(engineRoot, relativePath);
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination, 0);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-v3-to-v4",
providerPackage: "gelios.provider.v4",
capabilities: [
"gelios.monitoring_config.current.read",
"gelios.units.current.read",
],
dataProductId: "fleet.positions.current.v3",
credentialValues: "preserved",
untouched: ["n8n", "L1 graph", "Engine UI", "databases"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const path = join(engineRoot, relativePath);
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_composite_provider_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(path));
if (actual !== expected) {
throw new Error(
`engine_composite_provider_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
const provider = catalog?.packages?.[0];
const capabilityIds = provider?.capabilities?.map((item) => item.id);
const requestUrls = provider?.capabilities?.map((item) => item.request?.url);
if (
catalog?.schemaVersion !== "nodedc.engine.provider-security-catalog/v1" ||
provider?.id !== "gelios.provider.v4" ||
provider?.version !== "4.0.0" ||
provider?.providerCredential?.credentialType !== "httpBearerAuth" ||
JSON.stringify(capabilityIds) !== JSON.stringify([
"gelios.monitoring_config.current.read",
"gelios.units.current.read",
]) ||
JSON.stringify(requestUrls) !== JSON.stringify([
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
"https://api.geliospro.com/api/v1/units?incltrip=true",
]) ||
provider?.capabilities?.some(
(item) => item.dataProductIds?.length !== 1 ||
item.dataProductIds[0] !== "fleet.positions.current.v3",
)
) {
throw new Error("engine_composite_provider_catalog_projection_mismatch");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,156 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const sourceArtifact = resolve(
process.env.NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz',
)
const [patchId = '', ...extra] = process.argv.slice(2)
if (extra.length || !/^engine-credential-sink-recovery-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-credential-sink-recovery-artifact.mjs <fresh-recovery-patch-id>')
}
const sourceArtifactSha256 = '0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2'
const sourceManifest = 'id=engine-credential-sink-20260716-001\ncomponent=engine\ntype=app-overlay\n'
const entries = Object.freeze([
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
'nodedc-source/server/credentialSink',
'nodedc-source/server/index.js',
'nodedc-source/server/routes/engineAgentGateway.js',
'nodedc-source/server/routes/engineCredentialSink.js',
'nodedc-source/server/routes/n8n.js',
'nodedc-source/server/routes/ndcAgentMcp.js',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml',
])
const payloadSha256 = Object.freeze({
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
'nodedc-source/server/routes/engineAgentGateway.js': 'e3450a4e1d5318dbac37627b67804d62804e27e2032c9e90478a1e739cea6d3d',
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
'nodedc-source/server/routes/n8n.js': '783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48',
'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
})
const sourceInfo = await lstat(sourceArtifact)
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) {
throw new Error('engine_credential_sink_recovery_source_unsafe')
}
if (digest(await readFile(sourceArtifact)) !== sourceArtifactSha256) {
throw new Error('engine_credential_sink_recovery_source_sha256_mismatch')
}
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-credential-sink-recovery-'))
try {
run('python3', [
'-c', verifiedExtractScript(), sourceArtifact, stage,
sourceArtifactSha256, sourceManifest, JSON.stringify(entries), JSON.stringify(payloadSha256),
])
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
sourceArtifact,
sourceArtifactSha256,
entries,
payloadSha256,
changed: ['manifest.env:id'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_credential_sink_recovery_artifact_already_exists')
}
function verifiedExtractScript() {
return [
'import hashlib,json,pathlib,sys,tarfile',
'source=pathlib.Path(sys.argv[1]); stage=pathlib.Path(sys.argv[2])',
'expected_archive=sys.argv[3]; expected_manifest=sys.argv[4].encode()',
'entries=json.loads(sys.argv[5]); hashes=json.loads(sys.argv[6])',
"raw=source.read_bytes()",
"assert hashlib.sha256(raw).hexdigest()==expected_archive, 'source-sha256'",
"expected_files={'manifest.env','files.txt'}|{'payload/'+name for name in hashes}",
"expected_dirs={'payload'}",
"for name in hashes:",
" p=pathlib.PurePosixPath('payload/'+name)",
" expected_dirs.update(str(parent) for parent in p.parents if str(parent)!='.')",
"with tarfile.open(source,'r:gz') as tar:",
" members=tar.getmembers(); names={member.name for member in members}",
" assert names==expected_files|expected_dirs, 'source-member-set'",
" for member in members:",
" p=pathlib.PurePosixPath(member.name)",
" assert not p.is_absolute() and '..' not in p.parts and '\\\\' not in member.name, 'unsafe-member'",
" assert member.isdir() if member.name in expected_dirs else member.isfile(), 'unsafe-member-type'",
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'source-manifest'",
" expected_list=('\\n'.join(entries)+'\\n').encode()",
" assert tar.extractfile('files.txt').read()==expected_list, 'source-files-list'",
" for name,wanted in hashes.items():",
" data=tar.extractfile('payload/'+name).read()",
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload-sha256:'+name",
" target=stage/'payload'/name; target.parent.mkdir(parents=True,exist_ok=True); target.write_bytes(data)",
].join('\n')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
@@ -0,0 +1,297 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const [patchId = '', ...extra] = process.argv.slice(2)
const previouslyIssuedPatchIds = new Set([
'engine-data-product-publish-grant-20260716-001',
'engine-data-product-publish-grant-20260717-001',
'engine-data-product-publish-grant-20260717-002',
])
const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a'
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
}
if (previouslyIssuedPatchIds.has(patchId)) throw new Error('engine_publish_grant_patch_id_already_issued')
// Deliberately exclude frontend/dist, runtime data, tests, every credential
// value, and the already-installed credential-sink implementation. The sink
// remains mounted and byte-frozen as the predecessor domain; this artifact
// adds the exact Data Product Publish grant flow beside it.
const entries = Object.freeze([
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/dataProductPublishGrant',
'nodedc-source/server/engineAgents/store.js',
'nodedc-source/server/routes/engineAgentGateway.js',
'nodedc-source/server/routes/n8n.js',
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
])
const ignoredBasenames = new Set(['.DS_Store', '.git', 'node_modules'])
const credentialSinkPredecessorSha256 = Object.freeze({
'docker-compose.yml': '258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e',
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
'nodedc-source/server/routes/ndcAgentMcp.js': '534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
})
const publishGrantRuntimeOverride = [
'services:',
' nodedc-backend:',
' user: "0:0"',
' environment:',
' ENGINE_DATA_PLANE_BASE_URL: http://external-data-plane:18106',
' ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
' ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: /var/lib/nodedc-control-plane/publish-grants',
' volumes:',
' - type: bind',
' source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants',
' target: /var/lib/nodedc-control-plane/publish-grants',
' bind:',
' create_host_path: false',
' - type: bind',
' source: /volume1/docker/nodedc-platform/secrets/engine-edp-managed-provisioner/private-key.pem',
' target: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
' read_only: true',
' bind:',
' create_host_path: false',
'',
].join('\n')
await assertSourceBoundary()
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-publish-grant-artifact-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const entry of entries) {
await copySafe(resolve(engineRoot, entry), join(payload, entry))
}
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
const sha256 = digest(await readFile(artifact))
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256,
entries,
excluded: [
'docker-compose.yml',
'nodedc-source/server/index.js',
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
'nodedc-source/server/credentialSink',
'nodedc-source/server/routes/engineCredentialSink.js',
'nodedc-source/server/middleware/demoAccess.js',
'nodedc-source/server/routes/ndcAgentMcp.js',
'nodedc-source/server/data',
'nodedc-source/dist',
'nodedc-source/server/tests',
],
providerPackage: 'gelios.provider.v4',
providerRequests: [
'https://api.geliospro.com/api/v1/users/me/monitoring-config',
'https://api.geliospro.com/api/v1/units?incltrip=true',
],
dataProductId: 'fleet.positions.current.v3',
credentialValues: 'preserved',
preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertSourceBoundary() {
// These files are deliberately excluded from the artifact. Their exact
// installed predecessor is verified by the root-owned runner during plan
// and apply; the local builder only proves that it cannot package a link or
// another unsafe filesystem object in their place.
for (const relativePath of Object.keys(credentialSinkPredecessorSha256)) {
const info = await lstat(join(engineRoot, relativePath))
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_credential_sink_predecessor_unsafe:${relativePath}`)
}
}
const runtimeOverridePath = join(
engineRoot,
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
)
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
throw new Error('engine_publish_grant_runtime_override_mismatch')
}
const catalogPath = join(
engineRoot,
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
)
if (digest(await readFile(catalogPath)) !== compositeProviderCatalogTargetSha256) {
throw new Error('engine_composite_provider_catalog_target_mismatch')
}
const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
throw new Error('engine_agent_mcp_mount_missing')
}
if (
!indexSource.includes("import engineCredentialSinkRouter from './routes/engineCredentialSink.js'")
|| !indexSource.includes("app.use('/internal/engine-credential-sink', express.json({")
) throw new Error('engine_credential_sink_predecessor_mount_missing')
const gateway = await readFile(join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), 'utf8')
for (const tool of [
'engine_plan_data_product_publish_grant',
'engine_apply_data_product_publish_grant',
'engine_accept_data_product_publish_grant',
'engine_rollback_data_product_publish_grant',
]) {
if (!gateway.includes(tool)) throw new Error(`engine_publish_grant_tool_missing:${tool}`)
}
const n8nRoute = await readFile(join(engineRoot, 'nodedc-source/server/routes/n8n.js'), 'utf8')
if (n8nRoute.includes("from '../credentialSink/")) {
throw new Error('engine_publish_grant_depends_on_legacy_sink')
}
if (!n8nRoute.includes('engineDataProductPublishGrantN8nAdapter')) {
throw new Error('engine_publish_grant_native_adapter_missing')
}
if (!n8nRoute.includes('engineCredentialSinkN8nAdapter')) {
throw new Error('engine_credential_sink_native_adapter_missing')
}
const grantDirectory = join(engineRoot, 'nodedc-source/server/dataProductPublishGrant')
const grantFiles = (await readdir(grantDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
const expectedGrantFiles = [
'acceptance.js',
'providerCatalog.js',
'service.js',
'signedDataPlaneClient.js',
'store.js',
]
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
throw new Error('engine_publish_grant_source_set_mismatch')
}
const providerCatalogSource = await readFile(join(grantDirectory, 'providerCatalog.js'), 'utf8')
const grantServiceSource = await readFile(join(grantDirectory, 'service.js'), 'utf8')
const grantStoreSource = await readFile(join(grantDirectory, 'store.js'), 'utf8')
for (const marker of [
'function capabilityRequests(capability)',
'function exactHttpRequestUrl(n8n)',
'capabilityIds',
'providerRequestNodeIds',
'providerCredentialRefs.size !== 1',
]) {
if (!providerCatalogSource.includes(marker)) {
throw new Error(`engine_composite_provider_resolver_missing:${marker}`)
}
}
if (
!grantServiceSource.includes('capabilities: descriptor.capabilityIds')
|| !grantServiceSource.includes('providerRequestNodeIds: descriptor.providerRequestNodeIds')
) {
throw new Error('engine_composite_provider_plan_projection_missing')
}
if (
!grantStoreSource.includes('Array.isArray(raw.capabilityIds)')
|| !grantStoreSource.includes('Array.isArray(raw.providerRequestNodeIds)')
) {
throw new Error('engine_composite_provider_store_projection_missing')
}
for (const entry of entries) {
const source = resolve(engineRoot, entry)
const info = await lstat(source)
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
throw new Error(`engine_artifact_source_unsafe:${entry}`)
}
}
for (const source of [
...expectedGrantFiles.map((name) => join(grantDirectory, name)),
join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'),
join(engineRoot, 'nodedc-source/server/routes/n8n.js'),
join(engineRoot, 'nodedc-source/server/routes/ndcAgentMcp.js'),
join(engineRoot, 'nodedc-source/server/engineAgents/store.js'),
]) run('node', ['--check', source])
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_publish_grant_artifact_already_exists')
}
async function copySafe(source, destination) {
const info = await lstat(source)
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`)
if (info.isFile()) {
await mkdir(dirname(destination), { recursive: true })
await cp(source, destination, { force: false, verbatimSymlinks: true })
return
}
if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`)
await mkdir(destination, { recursive: true })
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignoredBasenames.has(entry.name) || entry.name.startsWith('.env')) continue
const childSource = join(source, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(engineRoot, childSource)}`)
}
await copySafe(childSource, join(destination, entry.name))
}
}
function canonicalTarScript() {
return [
'import gzip, io, pathlib, sys, tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-depttrans-zone-authority-v1-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-depttrans-zone-authority-v1-artifact.mjs " +
"<engine-depttrans-zone-authority-v1-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"1aac712a05e55137d69eedaf363969460ffe866288e8e18e35711967ab232f39",
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json":
"1482032178b4816e599df570face9b1dfa8ae1fa2943ac8f941c561e8cb9aa9d",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"e4c216b0affd89ddbd48abcec86febd48eb12a4c56507851daeb47f2ccd60c9a",
"nodedc-source/server/dataProductPublishGrant/service.js":
"408836564e9a422eb5e648cc24d764f4bfdf7f83d93306742e8615fa8186a642",
"nodedc-source/server/dataProductPublishGrant/store.js":
"ace5971d0772e9a9499f0849e6b754e3677cc2ca3080e573a12e26acf5a6c0c0",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-depttrans-zone-authority-v1-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-platform-service-authority-v1",
providerPackage: "moscow-department-of-transport.pmd-slow-zones.v1",
dataProductId: "map.zones.current.v2",
authorityBoundary: "platform-service",
platformService: "nodedc-map-gateway",
credentialValues: "preserved",
untouched: ["n8n", "L1 graph", "Engine UI", "databases", "MCP Nginx"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_depttrans_zone_authority_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_depttrans_zone_authority_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
const provider = catalog.packages?.find(
(item) => item.id === "moscow-department-of-transport.pmd-slow-zones.v1",
);
const request = provider?.capabilities?.[0]?.request;
if (
provider?.version !== "1.0.0"
|| provider?.providerId !== "moscow-department-of-transport"
|| provider?.providerCredential !== undefined
|| provider?.capabilities?.[0]?.dataProductIds?.[0] !== "map.zones.current.v2"
|| request?.authorityBoundary !== "platform-service"
|| request?.serviceId !== "nodedc-map-gateway"
|| request?.network !== "engine"
|| request?.url !== "http://map-gateway:18103/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current"
) throw new Error("engine_depttrans_zone_authority_catalog_projection_mismatch");
const marker = JSON.parse(await readFile(join(engineRoot, entries[1]), "utf8"));
if (
marker?.schemaVersion !== "nodedc.engine.platform-service-authority/v1"
|| marker?.id !== provider.id
|| marker?.serviceId !== request.serviceId
|| marker?.dataProductId !== provider.capabilities[0].dataProductIds[0]
) throw new Error("engine_depttrans_zone_authority_marker_projection_mismatch");
const resolver = await readFile(join(engineRoot, entries[2]), "utf8");
const service = await readFile(join(engineRoot, entries[3]), "utf8");
const store = await readFile(join(engineRoot, entries[4]), "utf8");
for (const marker of [
"function platformServiceRequestIsExact",
"pinned_platform_service_no_graph_credential",
"platformServiceIds",
"publish_grant_provider_request_path_unreachable",
]) {
if (![resolver, service, store].some((source) => source.includes(marker))) {
throw new Error(`engine_depttrans_zone_authority_marker_missing:${marker}`);
}
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
@@ -0,0 +1,276 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-l1-agent-init-concurrency-20260724-042", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-l1-agent-init-concurrency-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-l1-agent-init-concurrency-artifact.mjs "
+ "[engine-l1-agent-init-concurrency-YYYYMMDD-NNN]",
);
}
const sourceCommit = "c4077fe0d8d824fa58616eeb986dc78ca43070da";
const predecessorSha256 = Object.freeze({
"nodedc-source/src/App.tsx":
"b3e61a89330b774f309660208d05143d299ab582f18765c1c14e2122a62c4d5e",
"nodedc-source/src/driveinspector/nodes/N8n.definition.ts":
"a4890cca12b269643ec3255c2fc6b7e2be96aa042ffc6087772e63cbf8323c3a",
"nodedc-source/src/nodes/N8nNode.tsx":
"a125f7cde66085008dafe5a340a1143094c59769573815851ce6c8adfe4c0220",
"nodedc-source/src/store.ts":
"2128cd582a947582bb7736792c3a71551712262ef04882c189220ec79f16fb8e",
"nodedc-source/dist/index.html":
"90d72f8790dc8cf951066b1210447c84d640373117bae23f3a4cb3227fb96d6d",
"nodedc-source/dist/assets/index-CqvJfRRS.js":
"06e6c23b03ea2ba8a4890e1f1714fd08ebaf18d735834200af6ab430af360ca8",
});
const targetSha256 = Object.freeze({
"nodedc-source/src/App.tsx":
"fc580dc3a1a97393af81dd75c54ae8b37db1f6fda8364042198493493b15f2c2",
"nodedc-source/src/driveinspector/nodes/N8n.definition.ts":
"945839541547b2fd02b5424954e00483d6c76f82a333d6a7a652ccd1b74d04cf",
"nodedc-source/src/nodes/N8nNode.tsx":
"1130e355d86e06a18df775b2d3517bfdbf98fdc494ca707d23c78648fbfd3cb4",
"nodedc-source/src/store.ts":
"46884488254c16cd3af9b25556dd0db4f5a3a20047abdb496db831f204e40673",
"nodedc-source/src/utils/workflowWriteQueue.js":
"41f73ccb5e1dafbdae501b7c0b9fa5c47fdbd52564061b9c4ca6ca1c75ceccdf",
"nodedc-source/dist/index.html":
"1554e2418aead13b56328c5721dd677400d84ee087559a88fe60b295b7ff5412",
"nodedc-source/dist/assets/index-r1_2ECzJ.js":
"0d25dd843da7078b8dbad5cb4b78724bf1ca8aa067e9d0add1e09ce8f421b822",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
assertSourceCommit();
await assertPredecessorSources();
await assertExactTargets();
await assertRuntimeContract();
await assertFresh(artifact);
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-agent-init-"));
try {
for (const relativePath of entries) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
sourceCommit,
predecessorSha256,
targetSha256,
entries,
services: ["nodedc-backend", "app"],
healthchecks: [
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
],
concurrencyContract: "per-workflow-fifo-latest-revision-v1",
n8nCoreChanged: false,
untouched: [
"n8n runtime",
"L1 workflow data",
"L2 workflow data",
"credentials",
"databases",
"MCP catalogs",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function assertSourceCommit() {
const actual = run("git", ["-C", engineRoot, "rev-parse", "HEAD"]).stdout.trim();
if (actual !== sourceCommit) {
throw new Error(
`engine_l1_agent_init_source_commit_mismatch:`
+ `expected=${sourceCommit}:actual=${actual}`,
);
}
}
async function assertPredecessorSources() {
for (const [relativePath, expected] of Object.entries(predecessorSha256)) {
const result = run("git", [
"-C",
engineRoot,
"show",
`${sourceCommit}:${relativePath}`,
]);
const actual = digest(result.stdoutBuffer);
if (actual !== expected) {
throw new Error(
`engine_l1_agent_init_predecessor_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const newFile = "nodedc-source/src/utils/workflowWriteQueue.js";
const probe = spawnSync(
"git",
["-C", engineRoot, "cat-file", "-e", `${sourceCommit}:${newFile}`],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
if (probe.status === 0) {
throw new Error("engine_l1_agent_init_queue_not_new");
}
}
async function assertExactTargets() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const source = join(engineRoot, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_l1_agent_init_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(source));
if (actual !== expected) {
throw new Error(
`engine_l1_agent_init_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
}
async function assertRuntimeContract() {
const app = await readFile(join(engineRoot, "nodedc-source/src/App.tsx"), "utf8");
const definition = await readFile(
join(engineRoot, "nodedc-source/src/driveinspector/nodes/N8n.definition.ts"),
"utf8",
);
const store = await readFile(join(engineRoot, "nodedc-source/src/store.ts"), "utf8");
const node = await readFile(
join(engineRoot, "nodedc-source/src/nodes/N8nNode.tsx"),
"utf8",
);
const queue = await readFile(
join(engineRoot, "nodedc-source/src/utils/workflowWriteQueue.js"),
"utf8",
);
const index = await readFile(
join(engineRoot, "nodedc-source/dist/index.html"),
"utf8",
);
if (
!app.includes("createWorkflowWriteQueue")
|| !app.includes("putExistingWorkflow")
|| !app.includes("expectedRevision: currentWorkflowRevisionRef.current")
|| !queue.includes("previous.catch(() => undefined).then(() => operation())")
|| !definition.includes("nodeName: 'NDC'")
|| !definition.includes("default: 'NDC'")
|| !definition.includes("data?.title || data?.nodeName || 'NDC'")
|| !store.includes("nodeName: 'NDC'")
|| !node.includes("data?.nodeName ?? 'NDC'")
|| !index.includes("/assets/index-r1_2ECzJ.js")
|| /gelios|robot2b/i.test(queue)
) {
throw new Error("engine_l1_agent_init_runtime_contract_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: null,
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(
`${command}_failed:${Buffer.from(result.stderr || "").toString("utf8")}`
+ `${Buffer.from(result.stdout || "").toString("utf8")}`,
);
}
return {
stdoutBuffer: Buffer.from(result.stdout || ""),
stderrBuffer: Buffer.from(result.stderr || ""),
stdout: Buffer.from(result.stdout || "").toString("utf8"),
stderr: Buffer.from(result.stderr || "").toString("utf8"),
};
}
@@ -0,0 +1,300 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-provider-authority-diagnostics-20260723-029.tgz",
);
const baselineArtifactSha256 =
"b4a0a1f707bf9814a4decc3f7f261b8afffe5727af2129c14bcdc76f81675e38";
const descriptorRel =
"nodedc-source/services/node-intelligence/activation.json";
const baselineDescriptorSha256 =
"84b0e15a10cedf334ad04d6f31c908da2dc155503c9974f0eeb75b01e20fb884";
const predecessorGatewaySha256 =
"5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c";
const targetGatewaySha256 =
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4";
const targetDescriptorSha256 =
"63e7619c6971d02102583bb5d80d33ece293b952f113200fa0b76f0e88c2dd32";
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-l2-closed-loop-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-l2-closed-loop-artifact.mjs " +
"<engine-l2-closed-loop-YYYYMMDD-NNN>",
);
}
const sourceCommit = "dda405cff27977622af0c218abb4d4420c408536";
const entries = Object.freeze([
"nodedc-source/server/index.js",
"nodedc-source/server/l2/graphRepository.js",
"nodedc-source/server/realtime/ws.js",
"nodedc-source/server/routes/engineAgentGateway.js",
"nodedc-source/server/routes/n8n.js",
"nodedc-source/server/routes/ndcAgentMcp.js",
"nodedc-source/src/App.tsx",
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx",
"nodedc-source/src/realtime/useMultiplayer.ts",
"nodedc-source/src/utils/n8nApi.ts",
"nodedc-source/dist/index.html",
"nodedc-source/dist/assets",
descriptorRel,
]);
const targetSha256 = Object.freeze({
"nodedc-source/server/index.js":
"1896e1cade61579863c50ff3f52f2e81ff27f2a511a9d362db81925ee21cefad",
"nodedc-source/server/l2/graphRepository.js":
"94ad08e1bb7e7be04854f1f911e06ee1acd6e09631f33f4c01e42e230665ac33",
"nodedc-source/server/realtime/ws.js":
"82cfa833e05c2fc6d6049dd4564af06164b5c784fdfb82c243ca67519f4509c2",
"nodedc-source/server/routes/engineAgentGateway.js":
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4",
"nodedc-source/server/routes/n8n.js":
"752cb1524160adc1c95163c34e139b615c063d2ce8980f2a63879bddbd0f1e08",
"nodedc-source/server/routes/ndcAgentMcp.js":
"353a10291ceb93c3641ece60ec6e809a394be86f5ceb97cb44755178940409dd",
"nodedc-source/src/App.tsx":
"b3e61a89330b774f309660208d05143d299ab582f18765c1c14e2122a62c4d5e",
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx":
"683aa44ba92aeac4879889e2bdb5319282976d7680d620701578830ce9677087",
"nodedc-source/src/realtime/useMultiplayer.ts":
"a4d001d9914098e50a78d8abe0a66344d23680b7a19b3573aa7b6f48c8bb9c35",
"nodedc-source/src/utils/n8nApi.ts":
"6f16d4992c51ffcc1e71a7bd172fd9ceb440d6e1a74d69ef1db62e0ac4230b3c",
"nodedc-source/dist/index.html":
"90d72f8790dc8cf951066b1210447c84d640373117bae23f3a4cb3227fb96d6d",
"nodedc-source/dist/assets/index-Bim2pv1P.css":
"18322addd45c126a7b8396f36b005f3085fddba3e9b346dd2c910f6fa6987ebf",
"nodedc-source/dist/assets/index-CqvJfRRS.js":
"06e6c23b03ea2ba8a4890e1f1714fd08ebaf18d735834200af6ab430af360ca8",
[descriptorRel]: targetDescriptorSha256,
});
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
assertSourceCommit();
await assertExactSources();
const targetDescriptor = await buildTargetDescriptor();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l2-closed-loop-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === descriptorRel) {
await writeFile(destination, targetDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
const source = join(engineRoot, relativePath);
const info = await lstat(source);
if (info.isSymbolicLink()) {
throw new Error(`engine_l2_closed_loop_source_symlink:${relativePath}`);
}
if (info.isDirectory()) {
await cp(source, destination, {
recursive: true,
force: false,
errorOnExist: true,
});
} else if (info.isFile()) {
await copyFile(source, destination);
} else {
throw new Error(`engine_l2_closed_loop_source_unsafe:${relativePath}`);
}
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
sourceCommit,
entries,
targetSha256,
services: ["nodedc-backend", "app"],
healthchecks: [
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
],
mcpVersion: "0.7.0",
graphContract: "semantic-revision-cas-v1",
transition: "failed-030-partial-source-reconciliation",
predecessorDescriptorSha256: baselineDescriptorSha256,
targetDescriptorSha256,
predecessorGatewaySha256,
targetGatewaySha256,
recoveryArtifact: "nodedc-engine-l2-closed-loop-20260723-030.tgz",
actorPlanes: ["external_codex_mcp", "ai_workspace", "engine_ui"],
untouched: [
"L2 graph data",
"credentials",
"databases",
"n8n runtime",
"AI Workspace connector",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function assertSourceCommit() {
const actual = run("git", ["-C", engineRoot, "rev-parse", "HEAD"]).stdout.trim();
if (actual !== sourceCommit) {
throw new Error(
`engine_l2_closed_loop_source_commit_mismatch:expected=${sourceCommit}:actual=${actual}`,
);
}
}
async function assertExactSources() {
const sourceEntries = entries.filter((entry) => entry !== descriptorRel);
const expectedPaths = Object.keys(targetSha256)
.filter((entry) => entry !== descriptorRel)
.sort();
const actualPaths = [];
for (const entry of sourceEntries) {
const source = join(engineRoot, entry);
const info = await lstat(source);
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
throw new Error(`engine_l2_closed_loop_source_unsafe:${entry}`);
}
if (info.isDirectory()) {
const result = run("find", [source, "-type", "f", "-print"]);
for (const path of result.stdout.split("\n").filter(Boolean)) {
actualPaths.push(path.slice(engineRoot.length + 1));
}
} else {
actualPaths.push(entry);
}
}
actualPaths.sort();
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
throw new Error(
`engine_l2_closed_loop_source_set_mismatch:` +
`expected=${expectedPaths.join(",")}:actual=${actualPaths.join(",")}`,
);
}
for (const relativePath of expectedPaths) {
const actual = digest(await readFile(join(engineRoot, relativePath)));
if (actual !== targetSha256[relativePath]) {
throw new Error(
`engine_l2_closed_loop_target_mismatch:${relativePath}:` +
`expected=${targetSha256[relativePath]}:actual=${actual}`,
);
}
}
}
async function buildTargetDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error("engine_l2_closed_loop_baseline_artifact_mismatch");
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${descriptorRel}`,
]).stdout;
if (digest(Buffer.from(baseline, "utf8")) !== baselineDescriptorSha256) {
throw new Error("engine_l2_closed_loop_baseline_descriptor_mismatch");
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion !==
"nodedc.engine-node-intelligence-transition/v1" ||
descriptor?.action !== "activate" ||
descriptor?.releaseId !== "2.33.2-974a9fb3492f" ||
descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256 ||
descriptor?.source?.upstreamProjectionSha256 !==
"2dfa6b4f37d9bfa8b92a8109d4060d02dd2634ceb8f7924504b83ccf3fdd1523"
) {
throw new Error("engine_l2_closed_loop_baseline_descriptor_contract_mismatch");
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (digest(Buffer.from(rendered, "utf8")) !== targetDescriptorSha256) {
throw new Error("engine_l2_closed_loop_target_descriptor_mismatch");
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
)
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
const [transitionId = '20260720-004', ...extra] = process.argv.slice(2)
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error('usage: build-engine-mcp-autonomy-provider-v5-artifact.mjs [YYYYMMDD-NNN]')
}
const id = `engine-mcp-autonomy-provider-v5-${transitionId}`
const target = join(artifactRoot, `nodedc-${id}.tgz`)
const predecessorArtifact = join(
canonicalArtifactRoot,
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
)
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
const files = [
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
'nodedc-source/server/assets/engine-agent-npm/package.json',
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz',
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/engineAgents/store.js',
gatewayRel,
descriptorRel,
]
const expectedSha256 = new Map([
[files[0], 'c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074'],
[files[1], '2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a'],
[files[2], 'd007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a'],
[files[3], '63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620'],
[files[4], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
[files[5], '5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c'],
])
await assertFresh(target)
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-autonomy-provider-v5-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel)
const stat = await lstat(source)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
assertSha(await readFile(source), expectedSha256.get(rel), rel)
await mkdir(dirname(join(payload, rel)), { recursive: true })
await cp(source, join(payload, rel), { force: false })
}
const descriptor = JSON.parse(extractMember(
predecessorArtifact,
`payload/${descriptorRel}`,
))
if (
descriptor?.action !== 'activate'
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
) throw new Error('mcp_autonomy_predecessor_descriptor_mismatch')
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
await mkdir(artifactRoot, { recursive: true })
run('python3', ['-c', canonicalTarScript(), target, stage])
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: ['nodedc-backend'],
mcpVersion: '0.6.0',
installerVersion: '0.1.6',
authority: 'mcp-capability-intersect-user-objective',
retryBoundary: 'three-identical-failures-without-new-evidence',
providerPackages: ['gelios.provider.v4', 'gelios.provider.v5'],
targetDataProduct: 'fleet.positions.current.v4',
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'credential values'],
files,
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(path) {
try {
await lstat(path)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('artifact_already_exists')
}
function extractMember(archive, member) {
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
"with tarfile.open(p,'r:gz') as t:",
' m=t.getmember(name)',
" if not m.isfile(): raise SystemExit('member-not-file')",
' f=t.extractfile(m)',
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
return run('python3', ['-c', script, archive, member]).stdout
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes)
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
}
function sha(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
@@ -0,0 +1,186 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-classified-aspects-20260724-039", ...extra] =
process.argv.slice(2);
if (extra.length || !/^engine-mcp-classified-aspects-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-mcp-classified-aspects-artifact.mjs "
+ "[engine-mcp-classified-aspects-YYYYMMDD-NNN]",
);
}
const files = Object.freeze([
"nodedc-source/server/l2ExecutionPlan/compiler.js",
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
]);
const expectedSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"4854a62fb44cb3dd715f2cb8728740575453a666f5c31abe9d41bc2562be5e95",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"2e70b607b4a347592ce2f4732f6da0781ac94ec4829ac336021cb501fdafe9aa",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"2fce1c623a8acf9c01a43463e6d38eda71ad28379a2d91b2ca40f31f8dccf3c6",
});
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-classified-aspects-"));
await assertFresh(artifact);
await assertEngineBoundary();
try {
for (const relativePath of files) {
const source = join(engineRoot, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend", "app"],
engineCorePolicy: "provider-neutral-scalar-visibility-guards",
providerPackage: "gelios.provider.v10",
files,
expectedSha256,
untouched: [
"live L2 graphs",
"n8n L1",
"Engine UI source and dist",
"node-intelligence",
"databases",
"credentials",
"embedded Codex",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertEngineBoundary() {
for (const relativePath of files) {
const actual = digest(await readFile(join(engineRoot, relativePath)));
if (actual !== expectedSha256[relativePath]) {
throw new Error(
`classified_aspect_target_mismatch:${relativePath}:`
+ `expected=${expectedSha256[relativePath]}:actual=${actual}`,
);
}
}
const compiler = await readFile(join(engineRoot, files[0]), "utf8");
if (
!compiler.includes("guardMatches")
|| !compiler.includes("'1.3.0'")
|| /gelios|fleet\.units\.contacts/i.test(compiler)
) {
throw new Error("classified_aspect_compiler_boundary_invalid");
}
const executionCatalog = JSON.parse(
await readFile(join(engineRoot, files[1]), "utf8"),
);
const providerPackage = executionCatalog?.packages?.find(
(entry) => entry?.id === "gelios.provider.v10",
);
if (
providerPackage?.version !== "10.0.0"
|| !executionCatalog?.runtime?.compilerVersions?.includes("1.3.0")
|| !providerPackage?.profiles?.some(
(profile) =>
profile?.dataProductId === "fleet.units.contacts.current.v1",
)
) {
throw new Error("classified_aspect_execution_catalog_invalid");
}
const securityCatalog = JSON.parse(
await readFile(join(engineRoot, files[2]), "utf8"),
);
const securityPackage = securityCatalog?.packages?.find(
(entry) => entry?.id === "gelios.provider.v10",
);
if (
securityPackage?.version !== "10.0.0"
|| securityPackage?.capabilities?.length !== 1
|| JSON.stringify(securityPackage.capabilities[0]?.dataProductIds)
!== JSON.stringify(["fleet.units.contacts.current.v1"])
) {
throw new Error("classified_aspect_security_catalog_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const canonicalArtifactRoot = resolve(here, "../deploy-artifacts");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot);
const [transitionId = "20260718-003", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error("usage: build-engine-mcp-control-plane-artifact.mjs [YYYYMMDD-NNN]");
}
const id = `engine-mcp-control-plane-${transitionId}`;
const target = join(artifactRoot, `nodedc-${id}.tgz`);
const nodeIntelligenceArtifact = join(
canonicalArtifactRoot,
"nodedc-engine-node-intelligence-20260717-001.tgz",
);
const nodeIntelligenceArtifactSha256 = "d126afaa0c714fef26362aad4749e3f4647f551d6eb975f0133cdf9ff2e6fc4f";
const descriptorRel = "nodedc-source/services/node-intelligence/activation.json";
const gatewayRel = "nodedc-source/server/routes/engineAgentGateway.js";
const upstreamProjectionRel = "nodedc-source/server/nodeIntelligence/upstreamProjection.js";
const files = [
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
"nodedc-source/server/assets/engine-agent-npm/package.json",
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js",
"nodedc-source/server/dataProductReadGrant/acceptance.js",
"nodedc-source/server/dataProductReadGrant/service.js",
"nodedc-source/server/dataProductReadGrant/store.js",
"nodedc-source/server/engineAgents/store.js",
upstreamProjectionRel,
gatewayRel,
"nodedc-source/server/routes/n8n.js",
"nodedc-source/server/routes/ndcAgentMcp.js",
"nodedc-source/services/backend/data-product-read-grant/docker-compose.immutable-runtime.yml",
"nodedc-source/server/dataProductPublishGrant/service.js",
"nodedc-source/server/dataProductPublishGrant/store.js",
descriptorRel,
];
const expectedSha256 = new Map([
[files[0], "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445"],
[files[1], "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597"],
[files[2], "e74c0136d346f904b42589d75cb11a952b4d2f21153f1b057fba28e9acf4f95f"],
[files[3], "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d"],
[files[4], "c2a13d5eb49937fe70ec6451d0465cac54c19c7fae44448db099079473ec02fc"],
[files[5], "202dbe575b2f2e1c584aa7e6e99e38fa84f12496feb454e3dbd3f98e2d0396dd"],
[files[6], "65b8cdd6b603333bac1feb303e9791feb1e9da39dc9b5bfc3df3961273b0052e"],
[files[7], "c3fcdc59a794f57d92e3d2ac713ee28341347cebd25364c4060beaa3dc2151de"],
[files[8], "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f"],
[files[9], "761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131"],
[files[10], "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15"],
[files[11], "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8"],
[files[12], "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962"],
[files[13], "952df0cb2ac477e644f5f3a9d64b4872ce1da33356eeab0bec17dcb2931cf653"],
[files[14], "ded3832c9677345a988448ae8e69cef254fd9bf39d2de20cffa295c1feedcd7c"],
[files[15], "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c"],
]);
await assertFresh(target);
assertSha(await readFile(nodeIntelligenceArtifact), nodeIntelligenceArtifactSha256, "node intelligence artifact");
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-control-plane-"));
const payload = join(stage, "payload");
try {
await mkdir(payload, { recursive: true });
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel);
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
assertSha(await readFile(source), expectedSha256.get(rel), rel);
await mkdir(dirname(join(payload, rel)), { recursive: true });
await cp(source, join(payload, rel), { force: false });
}
const descriptorText = extractMember(
nodeIntelligenceArtifact,
`payload/${descriptorRel}`,
);
const descriptor = JSON.parse(descriptorText);
if (
descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f"
) throw new Error("node_intelligence_predecessor_descriptor_mismatch");
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel);
descriptor.source.upstreamProjectionSha256 = expectedSha256.get(upstreamProjectionRel);
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true });
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), target, stage]);
const artifactSha256 = sha(await readFile(target));
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256,
services: ["nodedc-backend"],
predecessorGatewaySha256: "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f",
targetGatewaySha256: expectedSha256.get(gatewayRel),
mcpVersion: "0.5.0",
installerVersion: "0.1.4",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function extractMember(archive, member) {
const script = [
"import pathlib,sys,tarfile",
"p=pathlib.Path(sys.argv[1]); name=sys.argv[2]",
"with tarfile.open(p,'r:gz') as t:",
" m=t.getmember(name)",
" if not m.isfile(): raise SystemExit('member-not-file')",
" f=t.extractfile(m)",
" if f is None: raise SystemExit('member-unreadable')",
" sys.stdout.buffer.write(f.read())",
].join("\n");
return run("python3", ["-c", script, archive, member]).stdout;
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes);
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}
@@ -0,0 +1,289 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-mcp-telemetry-catalog-20260723-035.tgz",
);
const baselineArtifactSha256 =
"4ce1563612499a19d653a174a4d73632e0aaac7dacd2eca4e7c01c87586b1155";
const nodeIntelligenceDescriptorPath =
"nodedc-source/services/node-intelligence/activation.json";
const predecessorNodeIntelligenceDescriptorSha256 =
"25ed3efd858aaf82c242dba501f0acc0c6c3dc91e8845707a4d3325750eab59f";
const targetNodeIntelligenceDescriptorSha256 =
"03b2ba120c3929e9cf99940ddc927082de376ceff762895a84103144202aef42";
const predecessorGatewaySha256 =
"69bfc91e913a3fad04e13aca86efb9d62f73c0c7d1f8f7200907494b29fa9e8d";
const targetGatewaySha256 =
"9020cf49a3558c0497fed4ecfd81367cbc11b5b249881804c29fe437900c71f8";
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-materialization-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-materialization-artifact.mjs "
+ "<engine-mcp-execution-plan-materialization-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanMaterializationV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"391fc81228fdacd6efc6c0868991a3485f71869708e49ac15819db6ccce1bfce",
"nodedc-source/server/routes/engineAgentGateway.js":
targetGatewaySha256,
"nodedc-source/server/l2ExecutionPlan/catalog.js":
"c35b4c7ad9319aabbf1366a11ff52a6e99d961893bafdfcb4e84fc5f24fc04be",
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"beb3f664073f9a372432643936a04d0cb0028cd695f759c68bd053e9cd892fe4",
"nodedc-source/server/l2ExecutionPlan/materializer.js":
"ee3bcfd06b3a5fa46800df974a2dedfdd55eeaf662329f837486c011a9bd713e",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"a153e040e0a592bad4375b98d9a1d83d148923aa0f0fd47d225b92eda281c4e9",
[descriptorPath]:
"52c0152cff49c251be5581a9209d2e63ba710c16e815bdd6ece24f7c9dd7e480",
[nodeIntelligenceDescriptorPath]:
targetNodeIntelligenceDescriptorSha256,
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const targetNodeIntelligenceDescriptor =
await buildTargetNodeIntelligenceDescriptor();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-materialization-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === nodeIntelligenceDescriptorPath) {
await writeFile(destination, targetNodeIntelligenceDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "telemetry-catalog-v1-to-execution-plan-materialization-v1",
mcpVersion: "0.9.0",
mcpSurface: "external-codex",
mcpTools: [
"engine_plan_l2_execution_plan_materialization",
"engine_apply_l2_execution_plan_materialization",
],
providerLogicAuthority: "trusted-provider-package",
unmanagedGraphPolicy: "explicit-adoption-required",
nodeIntelligenceRelease: "2.33.2-974a9fb3492f",
predecessorGatewaySha256,
targetGatewaySha256,
predecessorNodeIntelligenceDescriptorSha256,
targetNodeIntelligenceDescriptorSha256,
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
if (relativePath === nodeIntelligenceDescriptorPath) continue;
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_materialization_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_materialization_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const gatewaySource = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
for (const marker of [
"const ENGINE_AGENT_MCP_VERSION = '0.9.0'",
"name: 'engine_plan_l2_execution_plan_materialization'",
"name: 'engine_apply_l2_execution_plan_materialization'",
"'execution-plan.apply'",
]) {
if (!gatewaySource.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_materialization_gateway_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(
await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
),
)) {
throw new Error("engine_mcp_execution_plan_materialization_provider_hardcode");
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-l2-execution-plan-materialization-v1"
|| descriptor?.mcpVersion !== "0.9.0"
|| descriptor?.plan?.tool
!== "engine_plan_l2_execution_plan_materialization"
|| descriptor?.apply?.tool
!== "engine_apply_l2_execution_plan_materialization"
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|| descriptor?.unmanagedGraphPolicy !== "explicit-adoption-required"
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_materialization_descriptor_contract_mismatch",
);
}
}
async function buildTargetNodeIntelligenceDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_artifact_mismatch",
);
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${nodeIntelligenceDescriptorPath}`,
]).stdout;
if (
digest(Buffer.from(baseline, "utf8"))
!== predecessorNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_descriptor_mismatch",
);
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion
!== "nodedc.engine-node-intelligence-transition/v1"
|| descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_contract_mismatch",
);
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (
digest(Buffer.from(rendered, "utf8"))
!== targetNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_target_descriptor_mismatch",
);
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}
@@ -0,0 +1,268 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanModuleOwnershipV3.json";
const activationPath =
"nodedc-source/services/node-intelligence/activation.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/materializer.js":
"dabf0073520049d7a04d1962b29e591ae092b87b287a50534ad2d98b03ae683c",
"nodedc-source/server/routes/engineAgentGateway.js":
"17c5f502b3ceacc45278eef7418d08e1e55a8b3e46e00942e559d25566fdba41",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"7e34b93aa30b06cd52853b48013baef2970cc27e4fddc333a1011dc73ea8c08a",
[descriptorPath]:
"d187d539a219fec453e06c55c3f5486ef66059371b4ae9b145266307b2af9889",
[activationPath]:
"4cdeb85ebb2e43f088f095f75b7fc31aabd8ab81c8ac96ded4aafd7dbd8e30bd",
});
const entries = Object.freeze(Object.keys(targetSha256));
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-module-ownership-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-module-ownership-artifact.mjs "
+ "<engine-mcp-execution-plan-module-ownership-YYYYMMDD-NNN>",
);
}
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-module-ownership-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-plan-telemetry-runtime-v2-to-module-ownership-v3",
mcpVersion: "0.10.0",
mcpSurface: "external-codex",
compilerVersions: ["1.1.0", "1.2.0"],
materializationStrategies: [
"create_or_reconcile_owned",
"adopt_existing",
"adopt_existing_module",
],
moduleOwnership: {
nodeBindings: "exact-one-to-one",
retireBoundary: "closed",
sharedManualWebhook: "compatible-existing-configuration-preserved",
providerCredentialNodes: "engine-managed",
publisherNodes: "engine-managed",
},
providerPackage: {
added: "gelios.provider.v9",
legacyPreserved: "gelios.provider.v8",
},
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const materializer = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/materializer.js"),
"utf8",
);
for (const marker of [
"nodedc.engine.materialized-execution-plan-module/v1",
"adopt_existing_module",
"preserveBoundNodeIds",
"execution_plan_module_retire_boundary_not_closed",
"manual_webhook_same_method",
]) {
if (!materializer.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(materializer)) {
throw new Error("engine_mcp_execution_plan_module_ownership_provider_hardcode");
}
const gateway = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
if (
!gateway.includes("const ENGINE_AGENT_MCP_VERSION = '0.10.0'")
|| !gateway.includes("preserveBoundNodeIds")
|| !gateway.includes("adopt_existing_module")
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_gateway_contract_mismatch",
);
}
const catalog = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/server/assets/execution-plans/v1/catalog.json"),
"utf8",
));
const geliosV9 = catalog?.packages?.find(
(providerPackage) => providerPackage?.id === "gelios.provider.v9",
);
if (
geliosV9?.contractDigest
!== "sha256:7dd4ce4a45ce76e884ffa1e304bfaa521f552e9a45e535930f2d17b882ae23ed"
|| !catalog?.packages?.some(
(providerPackage) => providerPackage?.id === "gelios.provider.v8",
)
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_catalog_contract_mismatch",
);
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-l2-execution-plan-module-ownership-v3"
|| descriptor?.mcpVersion !== "0.10.0"
|| descriptor?.moduleOwnership?.adoptionStrategy !== "adopt_existing_module"
|| descriptor?.sharedBoundary?.configurationAuthority !== "existing-graph"
|| descriptor?.providerPackageTrust?.addedPackageId !== "gelios.provider.v9"
|| descriptor?.providerPackageTrust?.legacyPackagePreserved !== true
|| descriptor?.engineProviderHardcode !== false
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_descriptor_contract_mismatch",
);
}
const activation = JSON.parse(
await readFile(join(engineRoot, activationPath), "utf8"),
);
if (
activation?.action !== "activate"
|| activation?.releaseId !== "2.33.2-974a9fb3492f"
|| activation?.source?.gatewaySha256
!== targetSha256["nodedc-source/server/routes/engineAgentGateway.js"]
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_activation_contract_mismatch",
);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-execution-plan-sandbox-runtime-20260724-045", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-sandbox-runtime-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-sandbox-runtime-artifact.mjs "
+ "[engine-mcp-execution-plan-sandbox-runtime-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"64f5196a83018505c6dac77a0f8674c27941257a874eed9b024c1c94f169be2b",
"nodedc-source/server/deployTransitions/executionPlanSandboxRuntimeV4.json":
"a4692643afb86dfeeee6ca24aefcc181913e715eff38fa158667d2f10b901847",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-execution-plan-sandbox-runtime-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
executionPlanCompilerVersion: "1.4.0",
n8nVersion: "2.3.2",
codeNodeMode: "runOnceForAllItems",
sandboxGlobalDependencies: [],
responseByteBudget: "bounded-pure-javascript-utf8",
rawProviderPayloadAtPublish: "forbidden",
engineProviderHardcode: false,
n8nCoreChanged: false,
l1Changed: false,
credentialsChanged: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_execution_plan_sandbox_runtime_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_execution_plan_sandbox_runtime_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const compiler = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/executionPlanSandboxRuntimeV4.json",
),
"utf8",
));
if (
!compiler.includes("const encodedBytes = (value) => {")
|| compiler.includes("new TextEncoder().encode(JSON.stringify(value ?? null))")
|| !compiler.includes("return extracted.map((source) => ({ json:")
|| descriptor?.id !== "engine-mcp-l2-execution-plan-sandbox-runtime-v4"
|| descriptor?.predecessor !== "engine-mcp-l1-credential-provenance-v2"
|| descriptor?.runtimeCompatibility?.sandboxGlobalDependencies?.length !== 0
|| descriptor?.runtimeCompatibility?.responseByteBudget
!== "bounded-pure-javascript-utf8"
|| descriptor?.dataBoundary?.rawProviderPayloadAtPublish !== "forbidden"
|| descriptor?.engineProviderHardcode !== false
|| descriptor?.n8nCoreChanged !== false
) {
throw new Error("engine_execution_plan_sandbox_runtime_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,224 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanTelemetryRuntimeV2.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"6b783ad15c26dc7de0645082c8a426002d943138c70bf31a240247b16a33b6a0",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"5bdfc92284a7c836ff326e3a89b559110e376b32efd34b5f23f2bec272745781",
[descriptorPath]:
"68b275efb6303284336d8b637c966c24d891a4bd0b3fec4fb83247359929ef79",
});
const entries = Object.freeze(Object.keys(targetSha256));
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-telemetry-runtime-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-telemetry-runtime-artifact.mjs "
+ "<engine-mcp-execution-plan-telemetry-runtime-YYYYMMDD-NNN>",
);
}
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-telemetry-runtime-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-plan-materialization-v1-to-telemetry-runtime-v2",
mcpVersion: "0.9.0",
mcpSurface: "external-codex",
compilerVersions: ["1.1.0", "1.2.0"],
legacyRuntimePreserved: true,
telemetryAuthority: [
"trusted-telemetry-projection",
"visible-sensor-definition",
],
unprojectedParameters: "discarded",
rawProviderPayloadAtPublish: "forbidden",
providerLogicAuthority: "trusted-provider-package",
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image and descriptor",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const compiler = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
);
for (const marker of [
"if (compilerVersion === '1.1.0')",
"if (compilerVersion !== '1.2.0')",
'"msgParam", "msg_param"',
"descriptor.telemetryProjection",
'"message-param"',
"convertedSensorValue",
"visibleSensorDefinition",
]) {
if (!compiler.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(compiler)) {
throw new Error("engine_mcp_execution_plan_telemetry_runtime_provider_hardcode");
}
const catalog = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
),
"utf8",
));
if (
JSON.stringify(catalog?.runtime?.compilerVersions)
!== JSON.stringify(["1.1.0", "1.2.0"])
) {
throw new Error("engine_mcp_execution_plan_telemetry_runtime_catalog_mismatch");
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id
!== "engine-mcp-l2-execution-plan-telemetry-runtime-v2"
|| descriptor?.mcpVersion !== "0.9.0"
|| JSON.stringify(descriptor?.compilerTransition?.supported)
!== JSON.stringify(["1.1.0", "1.2.0"])
|| descriptor?.compilerTransition?.legacyRuntimePreserved !== true
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|| descriptor?.engineProviderHardcode !== false
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_telemetry_runtime_descriptor_contract_mismatch",
);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}
@@ -0,0 +1,176 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-profile-decoder-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-profile-decoder-artifact.mjs "
+ "<engine-mcp-execution-profile-decoder-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/executionProfileDecoderV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"1c2427c1d5830c40b1e8ae05f3d683fc39d07d7e0fe2431b0f6efbbb1d3fcb88",
[descriptorPath]:
"93e431902e9bcd3b828a82ed6b42b48d939051f21bf8824dafcf2addac8a711c",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-profile-decoder-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-flatted-numeric-string-preservation",
mcpSurface: "external-codex",
mcpTool: "engine_get_node_output_profile",
valuesIncluded: false,
rawExecutionDataIncluded: false,
untouched: [
"L2 graph",
"n8n",
"L1",
"Engine UI",
"databases",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_mcp_profile_decoder_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_profile_decoder_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const source = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
for (const marker of [
"const compactReference = Symbol('n8nCompactReference')",
"Top-level string entries are primitive values.",
"valuesIncluded: false,",
]) {
if (!source.includes(marker)) {
throw new Error(`engine_mcp_profile_decoder_marker_missing:${marker}`);
}
}
const descriptor = JSON.parse(await readFile(join(engineRoot, descriptorPath), "utf8"));
const expectedDescriptor = {
schemaVersion: "nodedc.engine.deploy-transition/v1",
id: "engine-mcp-execution-profile-decoder-v1",
component: "engine",
scope: "external-mcp-observability",
sourcePath: "nodedc-source/server/routes/n8n.js",
behavior: "preserve-top-level-numeric-string-primitives-in-flatted-execution-data",
acceptance: {
tool: "engine_get_node_output_profile",
valuesIncluded: false,
rawExecutionDataIncluded: false,
},
};
if (JSON.stringify(descriptor) !== JSON.stringify(expectedDescriptor)) {
throw new Error("engine_mcp_profile_decoder_descriptor_contract_mismatch");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,174 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-gelios-items-envelope-20260724-046", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-gelios-items-envelope-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-gelios-items-envelope-artifact.mjs "
+ "[engine-mcp-gelios-items-envelope-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"eb0c32fa0e8017b23e2d225fcb2d5805aa6dd1bf674e8aa248afc6a4079a7403",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"42104d1267ca7840446c0c02edd3f9ecb29f8da8eb3e8f384cbd6dca0f676c4c",
"nodedc-source/server/deployTransitions/geliosItemsEnvelopeV12.json":
"16e39f54ebae776de9acfdf0078c79291310eeca5a1f720eb8b82496f4d419a3",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-gelios-items-envelope-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
providerPackageTransition: "gelios.provider.v11-to-v12",
responseCollectionPath: "items",
historicalExecutionPackagePreserved: true,
activeSecurityAuthority: "gelios.provider.v12",
engineCompilerChanged: false,
n8nCoreChanged: false,
l1Changed: false,
ontologyChanged: false,
credentialsChanged: false,
rawProviderValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_gelios_items_envelope_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_gelios_items_envelope_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const executionCatalog = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/server/assets/execution-plans/v1/catalog.json"),
"utf8",
));
const securityCatalog = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/server/assets/provider-packages/v1/catalog.json"),
"utf8",
));
const descriptor = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/server/deployTransitions/geliosItemsEnvelopeV12.json"),
"utf8",
));
const executionIds = executionCatalog.packages.map(({ id }) => id);
const securityIds = securityCatalog.packages.map(({ id }) => id);
const v12 = executionCatalog.packages.find(({ id }) => id === "gelios.provider.v12");
if (
!executionIds.includes("gelios.provider.v11")
|| !v12?.profiles?.some((profile) => (
profile.id === "gelios.units.identity.warm.v1"
&& profile.dataProductId === "fleet.units.identity.current.v1"
))
|| securityIds.includes("gelios.provider.v11")
|| securityIds.filter((id) => id === "gelios.provider.v12").length !== 1
|| descriptor?.id !== "engine-mcp-gelios-items-envelope-v12"
|| descriptor?.predecessor !== "engine-mcp-l2-execution-plan-sandbox-runtime-v4"
|| descriptor?.responseEnvelope?.collectionPath !== "items"
|| descriptor?.responseEnvelope?.rawValuesCaptured !== false
|| Object.values(descriptor?.boundaries || {}).some((value) => value !== false)
) {
throw new Error("engine_gelios_items_envelope_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,230 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "engine-mcp-gelios-units-items-envelope-20260724-049",
...extra
] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-gelios-units-items-envelope-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-gelios-units-items-envelope-artifact.mjs "
+ "[engine-mcp-gelios-units-items-envelope-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"d0cc95fa5e55bda315947fdb13bd8a0d8931b9484677e4c4cc0dcb0e9614e215",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"1f1b866b6f837bffc6d529dfffd8bfa9c87495bfcd9ae3515a79765143d94b1d",
"nodedc-source/server/deployTransitions/geliosUnitsItemsEnvelopeV12Patch1.json":
"7a1594573e98342d3a3edcc0dc4f663f06b30ca24cc20adb5f36d50591066d96",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-gelios-units-items-envelope-"),
);
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(
checksum,
`${sha256} ${artifact.split("/").at(-1)}\n`,
"utf8",
);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
transition:
"registered-profiles-v2-to-gelios-v12.0.1-units-items-envelope",
mcpVersion: "0.12.0",
providerPackage: {
id: "gelios.provider.v12",
predecessorVersion: "12.0.0",
targetVersion: "12.0.1",
},
registeredProfile: "gelios.units.profile.cold.v1",
responseCollectionPath: "items",
existingMaterializerReused: true,
compilerVersion: "1.4.0",
engineRuntimeCodeChanged: false,
n8nCoreChanged: false,
l1Changed: false,
l2GraphChanged: false,
engineUiChanged: false,
databasesChanged: false,
credentialsChanged: false,
foundryChanged: false,
ontologyChanged: false,
providerEndpointsChanged: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_gelios_units_items_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_gelios_units_items_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const execution = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
),
"utf8",
));
const security = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
),
"utf8",
));
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/"
+ "geliosUnitsItemsEnvelopeV12Patch1.json",
),
"utf8",
));
const providerPackage = execution.packages.find(
(item) => item.id === "gelios.provider.v12",
);
const profile = providerPackage?.profiles.find(
(item) => item.id === "gelios.units.profile.cold.v1",
);
const extract = profile?.executionPlanTemplate?.steps.find(
(step) => (
step.kind === "extract_items"
&& step.config?.capabilityId === "gelios.units.current.read"
),
);
const securityPackage = security.packages.find(
(item) => item.id === "gelios.provider.v12",
);
if (
providerPackage?.version !== "12.0.1"
|| securityPackage?.version !== "12.0.1"
|| profile?.dataProductId !== "fleet.units.profile.current.v1"
|| profile?.executionPlanTemplate?.compilerVersion !== "1.4.0"
|| extract?.config?.response?.collectionPaths?.[0] !== "items"
|| descriptor?.id
!== "engine-mcp-gelios-units-items-envelope-v12-patch1"
|| descriptor?.predecessor
!== "engine-mcp-registered-execution-profiles-v2"
|| descriptor?.mcpVersion !== "0.12.0"
|| descriptor?.registeredProfile?.materializer
!== "existing-immutable-two-phase"
|| Object.values(descriptor?.boundaries || {}).some(Boolean)
) {
throw new Error("engine_gelios_units_items_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; "
+ "info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: "
+ "tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-l1-credential-provenance-20260724-044", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-l1-credential-provenance-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-l1-credential-provenance-artifact.mjs "
+ "[engine-mcp-l1-credential-provenance-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"af07edcf784c420855134ab9178f020d259a1cac703cd643418ab7b4eb94dbd3",
"nodedc-source/server/deployTransitions/l1CredentialProvenanceV2.json":
"5887da6e5cb611450e03a110be9786acbe47ae1b00217b8bf09e0967f71f6a3d",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-credential-provenance-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
tool: "engine_list_l2_credential_refs",
credentialScope: "same-l1-workflow",
localProvenanceSources: ["manual", "workflow-ref", "credentials-file"],
referencedSourceRequiresSyncPayload: true,
crossL1Sharing: false,
managedGrants: "target-local",
credentialValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_l1_credential_provenance_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_l1_credential_provenance_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/l1CredentialProvenanceV2.json",
),
"utf8",
));
if (
!route.includes("function engineAgentCredentialMayProveL1Provenance")
|| !route.includes("return isGlobalRegistryEntryAllowed(entry)")
|| !route.includes("engineAgentCredentialMayProveL1Provenance(item)")
|| descriptor?.id !== "engine-mcp-l1-credential-provenance-v2"
|| descriptor?.visibilityProof?.referencedSourceRequiresSyncPayload !== true
|| descriptor?.visibilityProof?.logicalKeyEqualityRequired !== true
|| descriptor?.binding?.applyOperation !== "assignCredentialRef"
|| descriptor?.crossL1Sharing !== false
|| descriptor?.candidateBoundary?.managedGrants !== "target-local"
) {
throw new Error("engine_l1_credential_provenance_runtime_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-l1-credential-reuse-20260724-043", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-l1-credential-reuse-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-l1-credential-reuse-artifact.mjs "
+ "[engine-mcp-l1-credential-reuse-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"6620e4bdd573e9f6b636a4b059eafef76d59da2fdb2183038fa5ec95357d8478",
"nodedc-source/server/deployTransitions/l1CredentialReuseV1.json":
"2ada49ef8bb2f146a9ea8d3c9ab5ee55f6a4e1b17e0f4b62a16f27368bd0eb05",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-credential-reuse-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
tool: "engine_list_l2_credential_refs",
credentialScope: "same-l1-workflow",
crossL1Sharing: false,
managedGrants: "target-local",
credentialValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_l1_credential_reuse_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_l1_credential_reuse_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/l1CredentialReuseV1.json",
),
"utf8",
));
if (
!route.includes("function buildEngineAgentL1CredentialContext")
|| !route.includes("candidateScope === 'l1' ? l1Context.graph : graph")
|| !route.includes("managed writer/reader grants")
|| descriptor?.id !== "engine-mcp-l1-credential-reuse-v1"
|| descriptor?.visibilityProof?.scope !== "same-l1-workflow"
|| descriptor?.binding?.applyOperation !== "assignCredentialRef"
|| descriptor?.crossL1Sharing !== false
|| descriptor?.managedGrants !== "target-local"
) {
throw new Error("engine_l1_credential_reuse_runtime_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,193 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-normalized-identity-search-20260724-041", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-normalized-identity-search-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-normalized-identity-search-artifact.mjs "
+ "[engine-mcp-normalized-identity-search-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"a2bc69c72f68e57ed27120d0c88f4ffe6310bbf0c4360c8b0dba0953ccaaf522",
"nodedc-source/server/routes/engineAgentGateway.js":
"4a9524fd954320277042c783b7b19cbb2172f075f27652c0eebfd743ffc47872",
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"01958d541c778002d33e3aead0cfe02df2084eb7222ce941cc7149d73a10f135",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"2b8e5ee3d73d16f3cd6e34d1f7394946a6270a17b0e9e6511f12921ba2561fd3",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"fe5256fd2ba295819daecc8d2acae34687e807978a6730dfa20703cb3bab0c1b",
"nodedc-source/server/deployTransitions/normalizedIdentitySearchV1.json":
"41738185fe103642912b0aa1c29c51860970c38f9f916cc3725e79e258b9ea7e",
"nodedc-source/services/node-intelligence/activation.json":
"3e7aeb1d28eb291461f79cd656ece6488bc6d372124088e92f53bf89c3373f61",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-normalized-identity-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
mcpSurface: "external-codex",
tool: "engine_find_normalized_subjects",
providerPackage: "gelios.provider.v11",
compilerVersionAdded: "1.4.0",
rawProviderPayload: "forbidden",
commandSurface: "absent",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_normalized_identity_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_normalized_identity_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const gateway = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const compiler = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
);
if (
!gateway.includes("const ENGINE_AGENT_MCP_VERSION = '0.11.0'")
|| !gateway.includes("engine_find_normalized_subjects")
|| !route.includes("normalized-fact-search")
|| !route.includes("rawExecutionDataIncluded: false")
|| !compiler.includes("boundedStringList")
|| !compiler.includes("boundedNamedValues")
|| /gelios|robot2b/i.test(compiler)
) {
throw new Error("engine_normalized_identity_runtime_boundary_invalid");
}
const transition = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/normalizedIdentitySearchV1.json",
),
"utf8",
));
const activation = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/services/node-intelligence/activation.json"),
"utf8",
));
if (
transition?.id !== "engine-mcp-normalized-identity-search-v1"
|| transition?.normalizedFactSearch?.commandSurface !== false
|| transition?.providerPackageTrust?.addedPackageId !== "gelios.provider.v11"
|| activation?.source?.gatewaySha256
!== expectedSha256["nodedc-source/server/routes/engineAgentGateway.js"]
|| activation?.predecessor?.gatewaySha256
!== "6b8c80fa997ef7c438d199a6ee942c5e37aebc4057667af417897fa636131db4"
) {
throw new Error("engine_normalized_identity_transition_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,161 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
)
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
const [transitionId = '20260718-005', ...extra] = process.argv.slice(2)
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error('usage: build-engine-mcp-ontology-sdk-artifact.mjs [YYYYMMDD-NNN]')
}
const id = `engine-mcp-control-plane-${transitionId}`
const target = join(artifactRoot, `nodedc-${id}.tgz`)
const predecessorArtifact = join(
canonicalArtifactRoot,
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
)
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
const files = [
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
'nodedc-source/server/assets/engine-agent-npm/package.json',
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz',
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/dataProductPublishGrant/providerCatalog.js',
'nodedc-source/server/engineAgents/store.js',
gatewayRel,
descriptorRel,
]
const expectedSha256 = new Map([
[files[0], 'adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137'],
[files[1], '0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455'],
[files[2], '72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb'],
[files[3], '4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a'],
[files[4], 'd5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c'],
[files[5], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
[files[6], '25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152'],
])
await assertFresh(target)
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-ontology-sdk-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel)
const stat = await lstat(source)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
assertSha(await readFile(source), expectedSha256.get(rel), rel)
await mkdir(dirname(join(payload, rel)), { recursive: true })
await cp(source, join(payload, rel), { force: false })
}
const descriptor = JSON.parse(extractMember(
predecessorArtifact,
`payload/${descriptorRel}`,
))
if (
descriptor?.action !== 'activate'
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
) throw new Error('mcp_control_plane_predecessor_descriptor_mismatch')
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
await mkdir(artifactRoot, { recursive: true })
run('python3', ['-c', canonicalTarScript(), target, stage])
const artifactSha256 = sha(await readFile(target))
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256,
services: ['nodedc-backend'],
predecessorGatewaySha256: '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15',
targetGatewaySha256: expectedSha256.get(gatewayRel),
mcpVersion: '0.6.0',
installerVersion: '0.1.5',
ontologyMcp: 'separate-read-only-proxy',
providerPackage: 'gelios.provider.v2',
providerCredential: 'httpQueryAuth',
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'provider credential values'],
files,
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(path) {
try {
await lstat(path)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('artifact_already_exists')
}
function extractMember(archive, member) {
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
"with tarfile.open(p,'r:gz') as t:",
' m=t.getmember(name)',
" if not m.isfile(): raise SystemExit('member-not-file')",
' f=t.extractfile(m)',
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
return run('python3', ['-c', script, archive, member]).stdout
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes)
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
}
function sha(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
@@ -0,0 +1,279 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "engine-mcp-registered-execution-profiles-20260724-048",
...extra
] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-registered-execution-profiles-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-registered-execution-profiles-artifact.mjs "
+ "[engine-mcp-registered-execution-profiles-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"940510499a9260dbff718b7b1f96f72f8e9ac593b460805af3007731578a7668",
"nodedc-source/server/l2ExecutionPlan/catalog.js":
"fec56b154ae483ad21bbaaeb4c55707bffce223c27874ac135ce58a9203259a0",
"nodedc-source/server/l2ExecutionPlan/registeredProfiles.js":
"3c521c1652c61c9d757197c76e053fd3ec602e13ef7f6a4856835ecc1a8a61d1",
"nodedc-source/server/routes/engineAgentGateway.js":
"8f04edc11251de86b825351c92338be9537207887cf802474b6b6d8c3cce4077",
"nodedc-source/services/node-intelligence/activation.json":
"3d72709e40b79c01a62a6af4bde911fca4f609d5ac487d284cf97fd8ebd73686",
"nodedc-source/server/deployTransitions/registeredExecutionProfilesV2.json":
"1db523f035a47c6b00b41b26efe1390ad2d24acece9d30dd039eff56626e934d",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-registered-execution-profiles-"),
);
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(
checksum,
`${sha256} ${artifact.split("/").at(-1)}\n`,
"utf8",
);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.12.0",
transition:
"gelios-items-envelope-v12-to-registered-profiles-v2-attested",
tools: [
"engine_list_l2_execution_profiles",
"engine_plan_registered_l2_execution",
],
registeredProfiles: 6,
existingMaterializerReused: true,
n8nCoreChanged: false,
l1Changed: false,
l2GraphChanged: false,
engineUiChanged: false,
databasesChanged: false,
credentialsChanged: false,
foundryChanged: false,
ontologyChanged: false,
nodeIntelligenceAttestationChanged: true,
nodeIntelligenceImageChanged: false,
nodeIntelligenceSourceChanged: false,
rawProviderValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_registered_execution_profiles_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_registered_execution_profiles_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
),
"utf8",
));
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/"
+ "registeredExecutionProfilesV2.json",
),
"utf8",
));
const registered = catalog.packages.flatMap(
(providerPackage) => (providerPackage.profiles || []).filter(
(profile) => profile.executionPlanTemplate,
),
);
if (
registered.length !== 6
|| registered.some((profile) => (
!profile.dataClass
|| !profile.cadence
|| profile.executionPlanTemplate?.compilerVersion !== "1.4.0"
|| profile.executionPlanTemplate?.connection?.collectionProfileId
!== profile.id
))
|| descriptor?.id !== "engine-mcp-registered-execution-profiles-v2"
|| descriptor?.predecessor !== "engine-mcp-gelios-items-envelope-v12"
|| descriptor?.mcpVersion !== "0.12.0"
|| descriptor?.tools?.apply
!== "engine_apply_l2_execution_plan_materialization"
|| descriptor?.nodeIntelligenceAttestation?.targetGatewaySha256
!== expectedSha256[
"nodedc-source/server/routes/engineAgentGateway.js"
]
|| descriptor?.nodeIntelligenceAttestation?.sidecarImageChanged !== false
|| descriptor?.nodeIntelligenceAttestation
?.nodeIntelligenceSourceChanged !== false
|| descriptor?.boundaries?.nodeIntelligenceAttestationChanged !== true
|| Object.entries(descriptor?.boundaries || {}).some(
([key, value]) => (
key !== "nodeIntelligenceAttestationChanged" && value !== false
),
)
) {
throw new Error("engine_registered_execution_profiles_boundary_invalid");
}
const gateway = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
const resolver = await readFile(
join(
engineRoot,
"nodedc-source/server/l2ExecutionPlan/registeredProfiles.js",
),
"utf8",
);
const nodeIntelligence = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/services/node-intelligence/activation.json",
),
"utf8",
));
if (
nodeIntelligence?.releaseId !== "2.33.2-974a9fb3492f"
|| nodeIntelligence?.source?.gatewaySha256
!== expectedSha256[
"nodedc-source/server/routes/engineAgentGateway.js"
]
) {
throw new Error(
"engine_registered_execution_profiles_attestation_invalid",
);
}
for (const marker of [
"engine_list_l2_execution_profiles",
"engine_plan_registered_l2_execution",
"const ENGINE_AGENT_MCP_VERSION = '0.12.0'",
]) {
if (!gateway.includes(marker)) {
throw new Error(
`engine_registered_execution_profiles_gateway_marker_missing:${marker}`,
);
}
}
if (
!resolver.includes("registered_execution_profile_ref_scope_denied")
|| !resolver.includes("targetBoundPlan")
|| /gelios|robot2b/i.test(resolver)
) {
throw new Error("engine_registered_execution_profiles_resolver_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; "
+ "info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: "
+ "tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,267 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-l2-closed-loop-20260723-031.tgz",
);
const baselineArtifactSha256 =
"33103bcf8a5f020855f3a095306b94158d9a452344ac7313120b1624a745e47e";
const nodeIntelligenceDescriptorPath =
"nodedc-source/services/node-intelligence/activation.json";
const predecessorNodeIntelligenceDescriptorSha256 =
"63e7619c6971d02102583bb5d80d33ece293b952f113200fa0b76f0e88c2dd32";
const targetNodeIntelligenceDescriptorSha256 =
"25ed3efd858aaf82c242dba501f0acc0c6c3dc91e8845707a4d3325750eab59f";
const predecessorGatewaySha256 =
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4";
const targetGatewaySha256 =
"69bfc91e913a3fad04e13aca86efb9d62f73c0c7d1f8f7200907494b29fa9e8d";
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-telemetry-catalog-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-telemetry-catalog-artifact.mjs "
+ "<engine-mcp-telemetry-catalog-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/telemetryReadingCatalogV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"903245ae363e9b9ac161498f17988a38876a0e0ac8d80f3fa1b0112ceb7fe906",
"nodedc-source/server/routes/engineAgentGateway.js":
targetGatewaySha256,
[descriptorPath]:
"b25ab8b6e6ad8ac24614c4630cc3635abe453464466d5a72238b05e48a24d882",
[nodeIntelligenceDescriptorPath]:
targetNodeIntelligenceDescriptorSha256,
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const targetNodeIntelligenceDescriptor =
await buildTargetNodeIntelligenceDescriptor();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-telemetry-catalog-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === nodeIntelligenceDescriptorPath) {
await writeFile(destination, targetNodeIntelligenceDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-profile-decoder-v1-to-telemetry-catalog-v1",
mcpVersion: "0.8.0",
mcpSurface: "external-codex",
mcpTool: "engine_get_telemetry_reading_catalog",
readingValuesIncluded: false,
rawExecutionDataIncluded: false,
nodeIntelligenceRelease: "2.33.2-974a9fb3492f",
predecessorGatewaySha256,
targetGatewaySha256,
predecessorNodeIntelligenceDescriptorSha256,
targetNodeIntelligenceDescriptorSha256,
untouched: [
"L2 graph",
"n8n",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
if (relativePath === nodeIntelligenceDescriptorPath) continue;
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_mcp_telemetry_catalog_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_telemetry_catalog_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const n8nSource = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
for (const marker of [
"function toSafeTelemetryReadingCatalog",
"key === 'sensor_readings' && pathName.endsWith('.attributes')",
"rawExecutionDataIncluded: false,",
]) {
if (!n8nSource.includes(marker)) {
throw new Error(`engine_mcp_telemetry_catalog_marker_missing:${marker}`);
}
}
const gatewaySource = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
for (const marker of [
"const ENGINE_AGENT_MCP_VERSION = '0.8.0'",
"name: 'engine_get_telemetry_reading_catalog'",
"/telemetry-reading-catalog?",
]) {
if (!gatewaySource.includes(marker)) {
throw new Error(`engine_mcp_telemetry_catalog_gateway_marker_missing:${marker}`);
}
}
const descriptor = JSON.parse(await readFile(join(engineRoot, descriptorPath), "utf8"));
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-telemetry-reading-catalog-v1"
|| descriptor?.mcpVersion !== "0.8.0"
|| descriptor?.readsOnly !== "normalized-attributes.sensor_readings"
|| descriptor?.neverReturns?.join(",")
!== "reading-value,raw-execution-data,raw-provider-payload,credential-shaped-data"
) {
throw new Error("engine_mcp_telemetry_catalog_descriptor_contract_mismatch");
}
}
async function buildTargetNodeIntelligenceDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error("engine_mcp_telemetry_catalog_baseline_artifact_mismatch");
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${nodeIntelligenceDescriptorPath}`,
]).stdout;
if (
digest(Buffer.from(baseline, "utf8"))
!== predecessorNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_baseline_descriptor_mismatch",
);
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion
!== "nodedc.engine-node-intelligence-transition/v1"
|| descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_baseline_descriptor_contract_mismatch",
);
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (
digest(Buffer.from(rendered, "utf8"))
!== targetNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_target_descriptor_mismatch",
);
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More