refactor: move Device Core source to standalone repository

This commit is contained in:
Codex
2026-08-21 12:23:21 +03:00
parent 40fbfcf351
commit 827bf0a58a
212 changed files with 0 additions and 41120 deletions
-12
View File
@@ -1,12 +0,0 @@
.git
.DS_Store
.env
.env.*
docs
node_modules
**/test
**/*.log
**/*.prev-*
**/*.next-*
runtime
secrets
-71
View File
@@ -1,71 +0,0 @@
# 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 standalone Hub application `Device Core` / `Device Manager` is the human
control-plane shell. Its server-owned BFF calls the disabled-by-default
management and query API in `device-control-core`; browsers never receive the
Core bearer token and never author actor, role, group or owner-scope headers.
The authorized project workspace exposes only bounded metadata for catalog,
routes/sessions, bindings, configuration state, command state, audit and access;
raw audit/configuration payloads, command parameters/transport refs, external
approval proofs, credential refs and restricted identifier digests remain in Core.
Foundry remains a downstream consumer for project-approved device data and is
not the device registry or administration boundary.
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.
@@ -1,33 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-release.v1",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "edge-core-channel-upgrade-v4",
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
},
"service": "device-control-core",
"composeActivation": "preserve-active-v4-topology",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"coreNetworks": [
"device-plane-private",
"device-plane-egress"
],
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"commandTransport": "disabled",
"gelios": "untouched",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
"rollback": "restore-preapply-source-and-core-runtime"
}
@@ -1,35 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-release.v2",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-control-core-release-20260812-024",
"artifactSha256": "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793"
},
"service": "device-control-core",
"composeActivation": "preserve-active-v4-topology",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"coreNetworks": [
"device-plane-private",
"device-plane-egress"
],
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"commandTransport": "typed-service-ping-v1",
"commandCatalog": "allowlisted-adapter-typed-commands-only",
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
"gelios": "untouched-legacy-only",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
"rollback": "restore-preapply-source-and-core-runtime"
}
@@ -1,39 +0,0 @@
{
"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"
}
@@ -1,25 +0,0 @@
{
"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"
}
@@ -1,33 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-bootstrap.v1",
"transitionId": "__PATCH_ID__",
"action": "activate",
"managerPredecessor": {
"patchId": "device-manager-release-20260811-010",
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
},
"failedPredecessor": {
"patchId": "device-manager-release-20260811-016",
"artifactSha256": "590405821b95b54088f926e0d3b2cdf9c704b339f6749da500c2bb64fe0e952d",
"backupId": "device-plane-device-manager-release-20260811-016-20260811-215941",
"invalidCoreCertificateSha256Fingerprint": "56:16:E0:3A:F4:03:85:FD:42:86:85:AF:2A:AF:1E:90:16:C8:F7:91:7C:AD:02:7D:B7:C2:ED:07:06:56:81:F6"
},
"service": "device-control-core",
"composeActivation": "dedicated-additive-override",
"identity": "runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "exact-invalid-unexported-failed-predecessor-only",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"commandTransport": "disabled",
"gelios": "untouched",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract",
"rollback": "restore-source-and-preapply-core-runtime"
}
@@ -1,72 +0,0 @@
{
"schemaVersion": "nodedc.device-edge.core-channel-source.v1",
"status": "source-accepted",
"authority": "DCPLATFORM-76/ADR-0001",
"designContract": "nodedc.device-edge.core-channel.v1",
"implementedAt": "2026-08-11",
"transport": {
"initiator": "device-gateway-core",
"listener": "device-edge-channel",
"protocol": "http2-bidirectional-ndjson",
"tls": "TLSv1.3-mutual-authentication",
"bearerAuthentication": false,
"genericTcpForwarding": false
},
"sourceComponents": [
"packages/device-edge-channel-contract",
"services/device-edge-channel",
"services/device-gateway-core"
],
"identity": {
"edgeRegistrationRequired": true,
"edgeCertificatePinRequired": true,
"coreCertificateAllowlistRequired": true,
"unknownOrRevokedIdentity": "reject",
"rotation": "one-active-plus-one-staged-generation",
"retiredFingerprint": "reject",
"privateKeysInSource": false,
"privateKeysInArtifact": false
},
"messageBoundary": {
"schema": "nodedc.device-edge.channel-envelope.v1",
"maximumEnvelopeBytes": 1048576,
"directionLocalSequence": true,
"unknownSchemaOrKind": "close-logical-session",
"acceptanceWindow": 128,
"trackerAckRule": "core-acceptance-required"
},
"realtime": {
"polling": false,
"keepaliveSeconds": 15,
"deadPeerSeconds": 45,
"reconnectMinimumSeconds": 1,
"reconnectMaximumSeconds": 30,
"delivery": "at-least-once-with-core-idempotency"
},
"sourceAcceptance": {
"devicePlaneTestsPassed": 193,
"tls13MutualAuthenticationTested": true,
"keepaliveTested": true,
"disconnectReconnectTested": true,
"certificateRotationOverlapAndRetirementTested": true,
"idempotentReplayTested": true,
"unknownAndRevokedIdentityTested": true,
"oversizedEnvelopeTested": true,
"coreUnavailableRejectionTested": true,
"crossSessionProgressAndPerSessionOrderingTested": true,
"boundedAcceptanceWindowTested": true,
"existingCoreImageBuild": "passed-no-cache",
"existingGatewayImageBuild": "passed-no-cache"
},
"runtime": {
"mutationInThisTransition": false,
"edgePort8443Published": false,
"trackerPort9921Published": false,
"synologyPublicIngress": false,
"trackerIngress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched"
},
"nextGate": "closed-port-synthetic-runtime-transition-under-DCPLATFORM-21",
"rollback": "source-revert-only-runtime-unchanged"
}
@@ -1,28 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v1",
"transitionId": "__PATCH_ID__",
"action": "upgrade",
"bootstrapPredecessor": {
"patchId": "device-edge-core-channel-bootstrap-20260812-018",
"artifactSha256": "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931"
},
"service": "device-control-core",
"composeActivation": "preserve-dedicated-additive-override",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved-requires-explicit-443-reconciliation",
"commandTransport": "disabled",
"gelios": "untouched",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract",
"rollback": "restore-bootstrap-018-source-and-preapply-core-runtime"
}
@@ -1,28 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v2",
"transitionId": "__PATCH_ID__",
"action": "upgrade",
"upgradePredecessor": {
"patchId": "device-edge-core-channel-upgrade-20260812-019",
"artifactSha256": "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438"
},
"service": "device-control-core",
"composeActivation": "preserve-dedicated-additive-override",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"commandTransport": "disabled",
"gelios": "untouched",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract",
"rollback": "restore-upgrade-019-source-and-preapply-core-runtime"
}
@@ -1,39 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v4",
"transitionId": "__PATCH_ID__",
"action": "upgrade",
"upgradePredecessor": {
"patchId": "device-edge-core-channel-upgrade-v2-20260812-021",
"artifactSha256": "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867"
},
"failedAttempt": {
"patchId": "device-edge-core-channel-upgrade-v3-20260812-022",
"artifactSha256": "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613",
"backupId": "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620"
},
"service": "device-control-core",
"composeActivation": "replace-core-network-membership-with-private-plus-egress",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"coreNetworks": [
"device-plane-private",
"device-plane-egress"
],
"removedCoreNetwork": "device-plane-control",
"composeCompatibility": "synology-compose-v2.20-no-gw-priority",
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"commandTransport": "disabled",
"gelios": "untouched",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
"rollback": "restore-upgrade-v2-021-source-and-preapply-core-runtime"
}
@@ -1,114 +0,0 @@
{
"schemaVersion": "nodedc.device-edge.core-channel.v1",
"status": "accepted-design",
"authority": "DCPLATFORM-76/ADR-0001",
"direction": "device-gateway-core-initiated",
"transport": {
"protocol": "http2-bidirectional-stream",
"tls": "TLSv1.3-mutual-authentication",
"edgeListen": "0.0.0.0:443",
"endpointSource": "device-control-core.edge-registration",
"browserAccess": "forbidden",
"bearerOnlyAuthentication": "forbidden",
"genericTcpForwarding": "forbidden"
},
"identity": {
"corePrivateKeyLocation": "synology-canonical-secret-boundary",
"edgePrivateKeyLocation": "edge-runner-managed-trust-boundary",
"privateKeysInArtifacts": false,
"certificateRotation": "generation-bound-audited",
"unknownOrRevokedEdge": "reject"
},
"networkBoundary": {
"synologyPublicIngress": false,
"synologyPortForward": false,
"vpsInitiatedSynologyConnection": false,
"subnetRoutes": false,
"exitNode": false,
"tailscaleSsh": false,
"dockerSocket": false,
"allowedEdgeListeners": [
"management-ssh",
"raw-device-tcp/9921",
"core-channel-mtls/443"
]
},
"messageContract": {
"versioned": true,
"bounded": true,
"requiredKeys": [
"schemaVersion",
"edgeRegistrationId",
"channelGeneration",
"trackerSessionId",
"adapterProfileRef",
"sequence",
"eventAt",
"receivedAt",
"messageKind",
"correlationId"
],
"unknownKind": "close-logical-session",
"rawArbitraryDestination": "forbidden"
},
"acknowledgement": {
"trackerPackageAck": "only-after-bounded-core-acceptance",
"coreUnavailable": "do-not-acknowledge-tracker-package",
"deduplicationKey": [
"edgeRegistrationId",
"channelGeneration",
"trackerSessionId",
"packageNumber",
"contentDigest"
],
"deliverySemantics": "at-least-once"
},
"pilotLimits": {
"maxTrackerSessions": 128,
"maxSessionsPerObservedSource": 16,
"maxNewConnectionsPerMinutePerObservedSource": 60,
"maxBufferedBytesPerTrackerSession": 262144,
"maxAggregateBufferedBytes": 33554432,
"maxEnvelopePayloadBytes": 1048576,
"keepaliveSeconds": 15,
"deadPeerSeconds": 45,
"reconnectMinimumSeconds": 1,
"reconnectMaximumSeconds": 30,
"durableEdgeSpool": false
},
"pilotSlo": {
"trackerAckBeforeDurableCoreAcceptance": 0,
"lossOfCoreAcceptedPackages": 0,
"edgeReceiveToCoreAcceptanceP95Milliseconds": 2000,
"edgeReceiveToCoreAcceptanceP99Milliseconds": 5000,
"channelReestablishmentP95Seconds": 60,
"channelReestablishmentHardCeilingSeconds": 120,
"deadCoreDetectionHardCeilingSeconds": 45,
"malformedOrUnauthenticatedAcceptedRecords": 0,
"availabilityCommitment": "deferred-until-measured"
},
"commandBoundary": {
"typedOnly": true,
"rawPayload": "forbidden",
"durableEdgeQueue": false,
"sentEqualsSuccess": false,
"protocolAckMeans": "acknowledged-not-verified",
"unsafeAutomaticRetry": "forbidden"
},
"preserved": [
"device-control-core-database",
"device-gateway-core",
"hub-authentik",
"engine",
"foundry-runtime",
"gelios-production-path"
],
"rollout": [
"source-and-ops-contract",
"closed-port-synthetic-core-channel",
"negative-network-and-identity-acceptance",
"separate-public-device-ingress-transition",
"one-device-pilot"
],
"rollback": "restore-closed-port-predecessor-without-vps-initiated-backhaul"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,22 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.backhaul.v1",
"mode": "tailscale-userspace-key-only-ssh-local-forward",
"runtimeHost": "koffyvngij",
"component": "device-edge-vps",
"tailscaleNodeName": "nodedc-b2-vps",
"tailnetDnsSuffix": "tail8d32ac.ts.net",
"targetHost": "100.109.216.21",
"targetPort": 2222,
"targetHostKeyFingerprint": "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30",
"targetUser": "device-backhaul",
"runtimeUser": "nodedc-backhaul",
"credentialBoundary": "private-key-readable-only-by-nodedc-backhaul",
"permitOpen": "127.0.0.1:9921",
"localForward": "127.0.0.1:19921",
"proxy": "tailscale-userspace-socks5-127.0.0.1:1055",
"keyIdentity": "nodedc-device-edge-vps-backhaul",
"publicB2Ingress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "remove-backhaul-unit-and-user-restore-foundation-key-ownership"
}
@@ -1,78 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.command-transport.v1",
"mode": "provider-neutral-typed-command-transport-over-accepted-core-channel",
"status": "active-typed-command-transport",
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
"component": "device-edge-vps",
"phase": "command-transport",
"runtimeHost": "koffyvngij",
"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012",
"predecessorArtifactSha256": "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec",
"runtimeUser": "nodedc-channel",
"runtimeService": "nodedc-device-edge-channel.service",
"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway",
"publicIngress": "tcp/443-mtls-core-channel+tcp/9921-bidirectional-tracker-session",
"trackerIngress": "preserved:allowlisted-adapters-only",
"initialAdapterProfile": "arusnavi.b2.internal.v1",
"commandTransport": "typed-service-ping-v1",
"commandCatalog": "allowlisted-adapter-typed-commands-only",
"allowedCommands": [
"service.ping"
],
"credentialBoundary": "transient-over-pinned-mtls-in-memory-until-single-tracker-write-never-stored-never-logged-never-returned",
"responseBoundary": "exact-adapter-parser-serv-ok-only",
"health": "127.0.0.1:18222",
"adapterHealth": "127.0.0.1:18221",
"rawDeviceTcp9921": "public-bidirectional-tracker-session-no-generic-forwarding",
"gelios": "untouched-legacy-only",
"tailscale": "absent",
"dataBoundary": "no-vps-database-no-business-logic-no-synology-route",
"resourceCeilings": {
"memory": "192M",
"swap": "0",
"cpu": "75%",
"tasks": 128,
"openFiles": 1024,
"sessions": 128,
"sessionsPerAddress": 16,
"connectionsPerMinutePerAddress": 60,
"sessionBufferBytes": 65536,
"aggregateBufferBytes": 33554432
},
"preserved": [
"management-ssh-key",
"accepted-node-runtime",
"accepted-core-channel-trust-and-registration",
"accepted-tracker-ingress",
"retired-tailnet-boundary",
"gelios-production-path"
],
"forbidden": [
"vps-initiated-synology-connection",
"generic-tcp-forwarding",
"tailscale-runtime",
"docker",
"public-health",
"vps-database",
"vps-business-logic",
"unregistered-adapter",
"raw-command",
"firmware-command",
"reboot-command",
"persistent-command-credential"
],
"acceptance": [
"exact-tracker-ingress-012-predecessor",
"single-non-root-edge-process",
"core-channel-remains-accepted",
"public-tracker-tcp-9921-listening",
"adapter-profile-allowlisted",
"typed-service-ping-only",
"exact-serv-ok-response-parser",
"bounded-session-and-buffer-limits",
"tailscale-remains-absent",
"no-vps-to-synology-route",
"gelios-untouched"
],
"rollback": "restore-exact-tracker-ingress-012-source-and-existing-runtime"
}
@@ -1,55 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.core-channel.v1",
"mode": "provider-neutral-core-initiated-mtls-http2",
"status": "closed-tracker-ingress",
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
"component": "device-edge-vps",
"phase": "core-channel",
"runtimeHost": "koffyvngij",
"runtimeUser": "nodedc-channel",
"runtimeService": "nodedc-device-edge-channel.service",
"runtime": "accepted-node-v22.23.2-no-docker",
"publicIngress": "tcp/443-mtls-only",
"health": "127.0.0.1:18222",
"trackerIngress": "disabled",
"rawDeviceTcp9921": "closed",
"commandTransport": "disabled",
"gelios": "untouched",
"privateKeyBoundary": "runner-managed-host-local-only",
"peerTrustPrerequisite": "exact-pinned-self-signed-core-certificate-and-fingerprint",
"tls": "TLSv1.3+h2+mutual-authentication",
"networkPrivilege": "CAP_NET_BIND_SERVICE-only-for-non-root-tcp-443",
"resourceCeilings": {
"memory": "128M",
"swap": "0",
"cpu": "50%",
"tasks": 64,
"openFiles": 1024
},
"preserved": [
"management-ssh-key",
"accepted-node-runtime",
"foundation-source",
"gelios-production-path"
],
"forbidden": [
"vps-initiated-synology-connection",
"generic-tcp-forwarding",
"tailscale-ssh-backhaul",
"docker",
"public-health",
"tracker-tcp/9921"
],
"acceptance": [
"exact-non-root-runtime-identity",
"tls13-h2-mutual-authentication",
"edge-server-and-core-client-self-signed-identities-mutually-pinned",
"core-initiated-channel-accepted",
"unknown-core-certificate-rejected",
"public-443-only-beside-management-ssh",
"tracker-tcp-9921-closed",
"loopback-health-contract",
"resource-ceilings-present"
],
"rollback": "close-443-stop-channel-restore-exact-accepted-foundation-without-backhaul-or-relay"
}
@@ -1,22 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.foundation.v1",
"mode": "static-runtime-key-only-ssh-default-deny-no-public-b2",
"runtimeHost": "koffyvngij",
"publicIpv4": "155.212.211.15",
"component": "device-edge-vps",
"nodeVersion": "22.23.2",
"nodeArchiveSha256": "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307",
"tailscaleVersion": "1.102.2",
"tailscaleArchiveSha256": "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8",
"serviceUser": "nodedc-edge",
"managementSsh": "root-key-only",
"managementKeyFingerprint": "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw",
"serverHostKeyFingerprint": "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w",
"firewall": "default-deny-public-22-only",
"tailscale": "userspace-needs-external-enrollment",
"backhaulKey": "runner-managed-new-ed25519",
"publicB2Ingress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-exact-ssh-firewall-service-and-absent-runtime-predecessor"
}
@@ -1,27 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.relay.v1",
"mode": "public-b2-opaque-bounded-relay",
"runtimeHost": "koffyvngij",
"publicIpv4": "155.212.211.15",
"component": "device-edge-vps",
"runtimeUser": "nodedc-relay",
"credentialAccess": "none",
"listen": "0.0.0.0:9921",
"health": "127.0.0.1:18221",
"privateUpstream": "127.0.0.1:19921",
"sourceAdmission": "public-ipv4-only",
"maxSessions": 128,
"maxSessionsPerAddress": 16,
"maxConnectionsPerMinutePerAddress": 60,
"maxTrackedSourceAddresses": 4096,
"maxBytesPerDirection": 67108864,
"sessionTimeoutMs": 300000,
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"dns": "unchanged",
"b2Routes": "unchanged",
"rollback": "close-9921-stop-relay-remove-user-and-restore-accepted-backhaul"
}
@@ -1,17 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.runtime-reconciliation.v1",
"component": "device-edge-vps",
"runtimeHost": "koffyvngij",
"publicIpv4": "155.212.211.15",
"transition": "recover-exact-runtime-executable-modes-after-failed-core-channel-publish",
"acceptedFoundationPatch": "device-edge-vps-foundation-20260806-003",
"acceptedFoundationArtifactSha256": "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a",
"failedPatch": "device-edge-vps-core-channel-20260812-001",
"failedArtifactSha256": "c199980e5754cf3e874a09f42e408fc88885cdbf7c872eac36c0ab768a7bab00",
"runtimeMutation": "restore-root-owned-executable-mode-0755-for-exact-known-binaries",
"publicCoreChannel": "disabled",
"trackerIngress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-exact-runtime-files-modes-and-reconciliation-marker"
}
@@ -1,55 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.tailscale-retirement.v1",
"mode": "retire-superseded-vps-tailnet-after-accepted-core-channel",
"status": "core-channel-only",
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
"component": "device-edge-vps",
"phase": "tailscale-retirement",
"runtimeHost": "koffyvngij",
"predecessorPatch": "device-edge-vps-core-channel-20260812-010",
"predecessorArtifactSha256": "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3",
"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust",
"publicIngress": "tcp/443-mtls-only",
"trackerIngress": "disabled",
"rawDeviceTcp9921": "closed",
"commandTransport": "disabled",
"gelios": "untouched",
"preserved": [
"management-ssh-key",
"accepted-node-runtime",
"accepted-core-channel-source-runtime-and-trust",
"core-channel-registration",
"foundation-source-for-audit-and-rollback",
"gelios-production-path"
],
"retired": [
"nodedc-b2-tailscaled.service",
"userspace-socks5-127.0.0.1:1055",
"tailscale-local-state",
"tailscale-runtime-binaries",
"superseded-backhaul-private-key"
],
"forbidden": [
"tailscale-runtime",
"tailnet-address",
"vps-initiated-synology-connection",
"generic-tcp-forwarding",
"tailscale-ssh-backhaul",
"docker",
"public-health",
"tracker-tcp/9921"
],
"acceptance": [
"exact-core-channel-010-predecessor",
"core-channel-remains-accepted",
"tailscale-service-absent-inactive-and-disabled",
"tailscale-userspace-listeners-absent",
"tailscale-local-state-and-runtime-binaries-absent",
"superseded-backhaul-private-key-absent",
"public-443-only-beside-management-ssh",
"tracker-tcp-9921-closed",
"command-transport-disabled"
],
"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok",
"rollback": "before-external-tailnet-revocation-restore-backed-up-local-tailscale-runtime-state-unit-and-core-channel-predecessor"
}
@@ -1,69 +0,0 @@
{
"schemaVersion": "nodedc.device-edge-vps.tracker-ingress.v1",
"mode": "provider-neutral-allowlisted-adapter-ingress-over-accepted-core-channel",
"status": "active-tracker-ingress",
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
"component": "device-edge-vps",
"phase": "tracker-ingress",
"runtimeHost": "koffyvngij",
"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011",
"predecessorArtifactSha256": "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877",
"runtimeUser": "nodedc-channel",
"runtimeService": "nodedc-device-edge-channel.service",
"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway",
"publicIngress": "tcp/443-mtls-core-channel+tcp/9921-tracker-telemetry",
"trackerIngress": "enabled:allowlisted-adapters-only",
"initialAdapterProfile": "arusnavi.b2.internal.v1",
"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance",
"health": "127.0.0.1:18222",
"adapterHealth": "127.0.0.1:18221",
"rawDeviceTcp9921": "public-telemetry-ingest",
"commandTransport": "disabled",
"gelios": "untouched",
"tailscale": "absent",
"dataBoundary": "no-vps-database-no-business-logic-no-synology-route",
"resourceCeilings": {
"memory": "192M",
"swap": "0",
"cpu": "75%",
"tasks": 128,
"openFiles": 1024,
"sessions": 128,
"sessionsPerAddress": 16,
"connectionsPerMinutePerAddress": 60,
"sessionBufferBytes": 65536,
"aggregateBufferBytes": 33554432
},
"preserved": [
"management-ssh-key",
"accepted-node-runtime",
"accepted-core-channel-trust-and-registration",
"retired-tailnet-boundary",
"gelios-production-path"
],
"forbidden": [
"vps-initiated-synology-connection",
"generic-tcp-forwarding",
"tailscale-runtime",
"docker",
"public-health",
"vps-database",
"vps-business-logic",
"unregistered-adapter",
"device-command"
],
"acceptance": [
"exact-tailscale-retirement-011-predecessor",
"single-non-root-edge-process",
"core-channel-remains-accepted",
"public-tracker-tcp-9921-listening",
"adapter-profile-allowlisted",
"bounded-session-and-buffer-limits",
"tracker-ack-after-core-acceptance",
"tailscale-remains-absent",
"no-vps-to-synology-route",
"command-transport-disabled",
"gelios-untouched"
],
"rollback": "close-9921-restore-exact-tailscale-retirement-011-source-unit-firewall-and-accepted-core-channel-runtime"
}
@@ -1,21 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-control-plane-reconciliation.v1",
"mode": "failed-control-plane-baseline-adoption",
"failedPatchId": "device-manager-control-plane-20260810-001",
"failedArtifactSha256": "50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6",
"backupId": "device-plane-device-manager-control-plane-20260810-001-20260811-000321",
"sourceAction": "publish-reconciliation-marker-only",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"absentService": "device-manager",
"databaseVolume": "nodedc-device-plane-postgres-data",
"publicIngress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "marker-only-runtime-unchanged"
}
@@ -1,10 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-control-plane.v1",
"action": "activate",
"service": "device-manager",
"publicIngress": "reverse-proxy-only",
"deviceCoreManagementApi": "file-token-authenticated",
"launcherTrust": "file-token-scoped-to-device-core-handoff",
"commandTransport": "disabled",
"gelios": "untouched"
}
@@ -1,24 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1",
"mode": "failed-v2-control-plane-baseline-adoption",
"failedPatchId": "device-manager-control-plane-20260811-003",
"failedArtifactSha256": "ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990",
"backupId": "device-plane-device-manager-control-plane-20260811-003-20260811-012505",
"failureClass": "deterministic-runtime-module-resolution",
"missingModule": "/packages/external-provider-contract/src/credential-reference.mjs",
"correctiveAction": "runtime-local-contract-adapter+staged-module-import-gate",
"sourceAction": "publish-reconciliation-marker-only",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"absentService": "device-manager",
"databaseVolume": "nodedc-device-plane-postgres-data",
"publicIngress": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "marker-only-runtime-unchanged"
}
@@ -1,17 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-control-plane.v2",
"action": "activate",
"predecessor": {
"patchId": "device-manager-control-plane-reconciliation-20260811-002",
"artifactSha256": "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6",
"mode": "failed-control-plane-baseline-adoption"
},
"service": "device-manager",
"publicIngress": "reverse-proxy-only",
"deviceCoreManagementApi": "file-token-authenticated",
"launcherTrust": "file-token-scoped-to-device-core-handoff",
"healthGate": "bounded-container-grace+core-contract",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-reconciled-baseline"
}
@@ -1,18 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v1",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-20260811-010",
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
},
"service": "device-manager",
"publicIngress": "reverse-proxy-only",
"deviceCoreManagementApi": "file-token-authenticated",
"launcherTrust": "file-token-scoped-to-device-core-handoff",
"healthGate": "bounded-container-grace+core-contract",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-preapply-snapshot"
}
@@ -1,21 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v2",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-20260811-010",
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
},
"service": "device-manager",
"publicIngress": "reverse-proxy-only",
"deviceCoreManagementApi": "file-token-authenticated",
"launcherTrust": "file-token-scoped-to-device-core-handoff",
"edgeChannel": "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges",
"edgeChannelIdentity": "runner-managed-host-local-private-key-public-certificate-export",
"edgeChannelEgress": "dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-8443-registration-policy",
"healthGate": "bounded-container-grace+core-contract",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-preapply-snapshot"
}
@@ -1,31 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v3",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-20260811-010",
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v2-20260812-025",
"artifactSha256": "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db"
},
"edgeChannelPredecessor": {
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
},
"service": "device-manager",
"publicIngress": "reverse-proxy-only",
"deviceCoreManagementApi": "file-token-authenticated",
"launcherTrust": "file-token-scoped-to-device-core-handoff",
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
"healthGate": "bounded-container-grace+core-contract",
"commandTransport": "typed-service-ping-v1",
"commandCatalog": "allowlisted-adapter-typed-commands-only",
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot"
}
@@ -1,30 +0,0 @@
{
"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"
}
@@ -1,23 +0,0 @@
{
"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"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,28 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.backhaul-vps-enrollment.v1",
"mode": "rotate-backhaul-client-mini-to-vps",
"predecessorPatchId": "device-plane-backhaul-target-tailnet-serve-20260804-002",
"predecessorArtifactSha256": "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba",
"sourceAction": "publish-vps-enrollment-marker-only",
"runtimeAction": "rotate-authorized-key-and-recreate-backhaul-target",
"selectedServices": [
"device-backhaul-target"
],
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"previousEnrollment": "device-edge-backhaul.pub",
"nextEnrollment": "device-edge-vps-backhaul.pub",
"nextKeyFingerprint": "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw",
"permittedTarget": "127.0.0.1:9921",
"tailnetAddress": "100.109.216.21",
"dockerPortPublication": "disabled",
"routerNatFirewall": "unchanged",
"edgePublicIngress": "disabled",
"funnel": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "restore-previous-authorized-key-and-recreate-target"
}
@@ -1,24 +0,0 @@
{
"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"
}
@@ -1,16 +0,0 @@
{
"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"
}
@@ -1,8 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
"service": "device-postgres",
"volume": "nodedc-device-plane-postgres-data",
"mode": "create-if-absent",
"ordinaryApplicationSelection": "forbidden",
"rollbackVolumePolicy": "preserve"
}
@@ -1,33 +0,0 @@
{
"schemaVersion": "nodedc.device-edge.superseded-transport.v1",
"status": "frozen",
"frozenAt": "2026-08-10",
"authority": "DCPLATFORM-76/ADR-0001",
"reason": "The public VPS must not initiate a private connection to Synology.",
"successor": "nodedc.device-edge.core-channel.v1",
"forbiddenForNewPlanOrApply": [
"nodedc.device-edge-vps.backhaul.v1",
"nodedc.device-edge-vps.relay.v1:privateUpstream=127.0.0.1:19921",
"nodedc.device-plane.backhaul-vps-enrollment.v1",
"tailscale-userspace-key-only-ssh-local-forward",
"rotate-backhaul-client-mini-to-vps"
],
"historicalSource": [
"deployment/device-edge-vps-backhaul-v1.json",
"deployment/device-edge-vps-relay-v1.json",
"deployment/device-plane-backhaul-vps-enrollment-v1.json",
"deployment/tailscale-device-edge-policy.hujson",
"vps/config/backhaul_ssh_config",
"vps/systemd/nodedc-b2-backhaul.service",
"vps/systemd/nodedc-b2-relay.service",
"infra/deploy-runner/build-device-edge-vps-artifact.mjs:backhaul|relay",
"infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs",
"infra/deploy-runner/nodedc-b2-vps-deploy:backhaul|relay"
],
"testOnlyReconstruction": {
"environment": "NODEDC_ALLOW_SUPERSEDED_TRANSPORT",
"value": "test-only",
"deployCandidate": false
},
"runtimeMutationInPhase0": false
}
@@ -1,55 +0,0 @@
// NODE.DC tailnet policy: user devices retain self-access while the public
// Robot2B Device Edge VPS receives one purpose-bound egress grant.
{
"tagOwners": {
"tag:device-edge-vps": ["autogroup:admin"],
},
"hosts": {
"device-plane-backhaul": "100.109.216.21",
"nodedc-admin-macbook": "100.114.248.4",
"nodedc-device-edge": "100.64.19.31",
},
"grants": [
// Preserve unrestricted connectivity only between devices owned by the
// same authenticated tailnet member. Tagged service nodes are excluded.
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"ip": ["*"],
},
// The public VPS can reach exactly the private SSH forwarding target.
{
"src": ["tag:device-edge-vps"],
"dst": ["device-plane-backhaul"],
"ip": ["tcp:2222"],
},
],
// Preserve the existing Tailscale SSH policy for user-owned devices.
"ssh": [
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"],
},
],
// These assertions are evaluated by Tailscale before every policy save.
"tests": [
{
"src": "tag:device-edge-vps",
"proto": "tcp",
"accept": ["device-plane-backhaul:2222"],
"deny": [
"device-plane-backhaul:22",
"device-plane-backhaul:5001",
"nodedc-admin-macbook:22",
"nodedc-device-edge:22",
],
},
],
}
@@ -1,36 +0,0 @@
services:
device-control-core:
environment:
DEVICE_EDGE_CHANNEL_ENABLED: "true"
DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers
DEVICE_EDGE_CHANNEL_MAX_EDGES: "32"
DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS: "15000"
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem
target: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem
target: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers
target: /run/nodedc-secrets/device-edge-channel/peers
read_only: true
bind:
create_host_path: false
networks:
- device-plane-egress
networks:
device-plane-egress:
name: nodedc-device-plane-egress
driver: bridge
internal: false
@@ -1,82 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,76 +0,0 @@
services:
device-control-core:
environment:
DEVICE_MANAGEMENT_API_ENABLED: "true"
DEVICE_MANAGEMENT_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
target: /run/nodedc-secrets/management-core-token
read_only: true
bind:
create_host_path: false
device-manager:
image: nodedc/device-manager:local
pull_policy: never
build:
context: ./services/device-manager
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: "18122"
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true"
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true"
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false"
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: device-core
NODEDC_LAUNCHER_BASE_URL: https://hub.nodedc.ru
NODEDC_LAUNCHER_INTERNAL_URL: http://launcher:5173
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120
NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
volumes:
- type: bind
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
target: /run/nodedc-secrets/device-core-internal-token
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
target: /run/nodedc-secrets/management-core-token
read_only: true
bind:
create_host_path: false
expose:
- "18122"
networks:
- device-plane-private
- platform-edge
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:18122/healthz').then(r=>r.json()).then(v=>{if(!v.ok||!v.authRequired||!v.deviceCoreConfigured)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
platform-edge:
external: true
name: nodedc-platform_edge
@@ -1,39 +0,0 @@
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
@@ -1,158 +0,0 @@
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_EDGE_CHANNEL_ENABLED: "false"
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
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
@@ -1,367 +0,0 @@
# ADR 0001: Core-initiated Device Gateway Edge channel
Status: accepted for implementation on 2026-08-10.
Scope: NODE.DC Device Core / Device Gateway / public Device Edge VPS.
Authority: NDC PLATFORM `DCPLATFORM-76`, with deploy implementation governed by
`DCPLATFORM-21`.
## Decision
The NODE.DC Core side initiates and owns the only private control/data channel
to a public Device Gateway Edge. The Edge never initiates a network connection
to Synology, DSM, Docker, Hub, Engine, Foundry, PostgreSQL, a Mac workstation or
another LAN service.
The target transport is TLS 1.3 mutual authentication over one long-lived
HTTP/2 bidirectional session:
```text
ARUSNAVI B2
-> raw TCP/9921
-> Device Gateway Edge on the VPS
-> accepted Core-owned mTLS session on standard HTTPS TCP/443
-> Device Gateway Core on Synology
-> Device Control Core
```
The Core dials an endpoint selected from an approved Edge registration. The
endpoint is not hardcoded into an adapter, device record, Foundry application,
Engine workflow or deployment artifact.
`device.nodedc.ru` remains the HTTPS user surface for Device Core. It is not the
raw B2 endpoint and is not moved to the VPS for this transport.
## Why this direction is mandatory
The VPS is a public, replaceable and partially untrusted edge host. A VPS owner
or a compromised root account must not gain a route into the private NODE.DC
network. An outbound Core connection works through NAT without a Synology port
forward and gives the Core a single explicit peer and protocol to validate.
The previous design used a tagged userspace Tailscale process on the VPS,
SOCKS5, SSH local forwarding and a Synology backhaul target. Although that
design had narrow ACLs, the trust direction was still VPS-initiated and it kept
an avoidable private-network membership on the public host. It is superseded.
## Component ownership
Device Gateway Edge owns only:
- public tracker TCP sessions;
- allowlisted adapter framing and protocol timing;
- the minimum HEADER/PACKAGE acknowledgement state;
- bounded in-memory buffers and flow-control counters;
- the server side of the mutually authenticated Core channel;
- typed command delivery to an already connected tracker session.
Device Gateway Core and Device Control Core own:
- Edge registration and certificate identity;
- owner scopes, Device Projects and project access;
- enrollment, quarantine, claim and transfer policy;
- restricted identifiers and credential references;
- complete telemetry decoding/normalization and data classification;
- command policy, confirmation, ledger and audit;
- Engine/Data Product and Foundry bindings.
The Edge does not contain a business database, durable telemetry store, Hub or
Authentik credentials, Engine/Foundry tokens, PostgreSQL credentials or the
Core client private key.
## Adapter split
One versioned adapter package exposes explicit role-scoped interfaces:
- `edge-session`: framing, bounded validation, HEADER/PACKAGE ACK and typed
command wire encoding required by the active socket;
- `core-decoder`: complete tag decoding, safe observation normalization and
model/firmware capability mapping;
- `command-contract`: typed commands and acknowledgement semantics shared by
policy and delivery code.
The Edge artifact contains only the allowlisted `edge-session` and required
wire command implementation. It does not contain owner, workflow or Foundry
logic. The Core artifact may contain the full adapter package. A new device
model is added through the adapter registry; it does not create another Device
Manager application or a model-specific Synology service.
## Authentication and key ownership
- The Core has a unique client certificate and private key stored only in the
canonical Synology secret/trust boundary.
- The Edge has a unique server certificate and private key stored only in the
Edge runtime trust boundary.
- Both certificates chain to the dedicated Device Edge private CA or an
equivalent separately approved workload-identity issuer.
- The Core verifies the Edge registration id, certificate identity, CA,
validity and configured endpoint.
- The Edge accepts only an approved Core workload identity and never accepts a
browser, bearer-token-only or anonymous channel.
- Certificates have bounded lifetime, explicit generation and audited rotation.
- No private key, enrollment token or certificate bundle is carried in Git,
Ops, MCP payloads or ordinary deploy artifacts.
TLS terminates on the Edge process. Root compromise of the VPS can therefore
read tracker traffic and impersonate that Edge identity until it is revoked.
It cannot obtain the Core private key or open a new connection into Synology.
## Session protocol
The application protocol is versioned and fail-closed. Every envelope has:
- schema version;
- Edge registration id;
- channel generation;
- tracker session id;
- adapter/profile reference and version;
- monotonically increasing direction-local sequence;
- event timestamp and receive timestamp;
- bounded payload length;
- message kind and correlation id.
Allowed Edge-to-Core messages are limited to channel hello/health, tracker
session opened/closed, discovery evidence, bounded verified frame, delivery
acknowledgement and bounded counters. Allowed Core-to-Edge messages are limited
to channel acceptance, flow-control window, session disposition and typed
command delivery.
Unknown schema versions, message kinds, adapters, Edge ids or oversized frames
close the logical session and create a safe audit event. They never fall back
to arbitrary TCP forwarding.
## Tracker acknowledgement rule
The VPS has no durable business store. It must not acknowledge a valid tracker
PACKAGE merely because bytes reached the VPS.
The Edge sends the verified frame to Core and waits for a bounded Core
acceptance acknowledgement. Only then may the Edge send the protocol PACKAGE
acknowledgement to the tracker. If the Core channel is unavailable or the
acceptance deadline expires, the Edge does not acknowledge the PACKAGE; the
tracker remains responsible for its documented retry behavior.
HEADER acknowledgement follows the same ownership boundary: discovery must be
accepted into Core quarantine or matched to an admitted device session before
the Edge completes the handshake. An unavailable Core means no admitted
tracker session.
This gives at-least-once delivery without a VPS database. Core deduplicates by
Edge generation, tracker session, package number and content digest.
## Realtime and flow control
The Core does not poll the VPS for batches. Telemetry returns immediately over
the established channel. The initial implementation contract is:
- one Core channel per Edge generation;
- maximum 128 concurrent tracker sessions on the pilot VPS;
- maximum 16 sessions per observed source address;
- maximum 60 new tracker connections per minute per observed source;
- maximum 256 KiB buffered per tracker session;
- maximum 32 MiB aggregate tracker/channel buffering;
- maximum 1 MiB for one protocol frame before adapter-specific lower limits;
- 15-second keepalive and 45-second dead-peer deadline;
- reconnect with jittered exponential delay from 1 to 30 seconds;
- no unbounded disk spool;
- explicit accepted, duplicate, late, dropped, rejected and throttled counters.
These are pilot ceilings, not a claim that one 961 MiB VPS supports production
scale. Load gates at 1, 100 and 1000 synthetic sessions determine the production
Edge topology.
## Pilot service objectives
These are acceptance objectives for the controlled pilot, not a published
production SLA:
- zero tracker PACKAGE acknowledgements before durable Core acceptance in all
normal, timeout, disconnect and Core-restart tests;
- zero loss of Core-accepted packages in the acceptance run; duplicate delivery
is permitted on reconnect but must collapse to one normalized observation;
- Edge receive to Core acceptance latency at 128 concurrent synthetic sessions:
p95 at or below 2 seconds and p99 at or below 5 seconds;
- after both peers and the network are healthy, channel re-establishment: p95 at
or below 60 seconds and hard acceptance ceiling of 120 seconds;
- dead Core detection at the Edge no later than 45 seconds after the last valid
channel activity;
- memory stays inside the configured 32 MiB aggregate application buffer plus
the separately measured fixed runtime baseline; exceeding a bound throttles
or closes the offender and never expands the limit;
- malformed, unknown, unauthenticated and revoked-identity inputs produce zero
accepted telemetry records and zero command deliveries;
- a typed command for a currently connected test tracker reaches Edge wire
delivery or a conclusive rejection in p95 at or below 2 seconds; verified
device execution is a separate adapter-defined objective.
Availability percentage and the 1000-session production capacity are explicitly
uncommitted until measured on the target host and recorded by a later transition.
## Commands
Commands use the same Core-owned channel and the existing tracker TCP session.
The VPS never exposes a command HTTP API and never accepts an arbitrary raw
payload.
Core sends a typed command containing exact device/session, adapter/profile,
command catalog version, parameters, idempotency key, expiry and correlation
id. Edge either rejects it before wire delivery or returns delivery evidence.
Protocol acknowledgement is `acknowledged`, not `verified`. Verification needs
a subsequent device observation or explicit readback defined by the adapter.
No command is queued durably on the VPS. Channel loss before a conclusive
outcome yields `unknown` or `failed` according to the command contract. Unsafe
automatic retry is forbidden.
## Network boundary
The target Edge exposes only:
- management SSH according to the separately accepted management policy;
- raw tracker ingress TCP/9921;
- Core channel TCP/443 with mandatory mTLS; this is not a browser or bearer-token endpoint.
Synology exposes no public device or backhaul port. The Edge receives no subnet
route, exit-node capability, Tailscale SSH, Docker socket or generic proxy. The
Core channel cannot request an arbitrary destination or port.
Firewall policy is default deny. Raw ingress and Core channel have independent
connection/rate/resource limits. DDoS and malformed traffic terminate at the
provider/VPS and cannot be forwarded as a generic byte stream to Synology.
## Threat model
Protected assets are the Synology/LAN network, Device Core data and credentials,
Hub/Authentik identities, Engine and Foundry capabilities, command authority and
the integrity of admitted telemetry.
The design assumes any of the following can happen independently: VPS root is
compromised; the VPS owner makes an unsafe change; an Internet client floods or
sends malformed B2 traffic; an Edge certificate is copied; a tracker identifier
is spoofed; the Core channel is interrupted; a valid package is replayed; or a
command outcome becomes unknown during disconnect.
The boundary mitigates lateral entry into the private platform, arbitrary TCP
proxying, anonymous/bearer-only channel access, unbounded memory growth, replay
as a second normalized observation, command injection through a raw API, secret
distribution to the VPS and acknowledgement of telemetry that only reached
volatile Edge memory.
Residual risks are explicit: compromised VPS root can read, drop, delay or forge
traffic attributed to that Edge until revocation, attack trackers connected to
it and exhaust the VPS or its uplink. Provider-scale DDoS protection, tracker
firmware trust and physical tracker compromise are outside this component. None
of those residual risks grants an inbound route or credential to Synology.
## Tailscale decision
Direct mTLS is the accepted target. Tailscale is not required for the product
channel.
The current VPS userspace Tailscale foundation is a live predecessor and is not
removed in Phase 0. A later canonical transition removes it after the mTLS
channel is accepted. If direct mTLS proves impossible for an externally
evidenced reason, a new ADR may admit Tailscale only with all of the following:
- Core still initiates the application session;
- tagged service identity;
- no user ownership, subnet routes, DNS, exit node or Tailscale SSH;
- an ACL to one exact Edge application endpoint only;
- negative tests for DSM 22/5001, Docker, MacBook and LAN;
- no SSH LocalForward or generic SOCKS backhaul.
The old VPS-to-Synology SSH local-forward is not an allowed fallback.
## Superseded source
The following source is historical/recovery evidence and must not be used for a
new plan or apply:
- `deployment/device-edge-vps-backhaul-v1.json`;
- `deployment/device-edge-vps-relay-v1.json` when it forwards to port 19921;
- `deployment/device-plane-backhaul-vps-enrollment-v1.json`;
- `deployment/tailscale-device-edge-policy.hujson` for the old SSH target;
- `vps/config/backhaul_ssh_config`;
- `vps/systemd/nodedc-b2-backhaul.service`;
- `vps/systemd/nodedc-b2-relay.service` with the old upstream;
- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`;
- `infra/deploy-runner/nodedc-b2-vps-deploy` backhaul/relay phases.
Builders fail closed by default. A test-only environment switch may reconstruct
historical artifacts for deterministic regression tests, but artifacts built in
that mode are not deploy candidates. Both reviewed runner sources reject the
superseded VPS phases and Synology enrollment even if such an archive exists.
## Deployment boundaries
The successor is a new additive Edge transport generation, not a weakened edit
of the old phase. Before an application artifact exists, DCPLATFORM-21 must
define:
- component and transition identity;
- exact payload allowlist/denylist;
- fixed Edge roots, systemd units and trust roots;
- runtime-secret ownership and rotation;
- predecessor checks for the current Tailscale foundation;
- services stopped/started and preserved state;
- health and negative network acceptance inside apply;
- automatic rollback to the current closed-port predecessor.
The first successor apply must leave TCP/9921 closed. It accepts only the mTLS
Core channel in a no-device/synthetic mode. Public tracker ingress is a separate
later transition after Core-channel acceptance.
## Acceptance gates
Phase 0 is accepted when:
- this ADR and a machine-readable contract are present;
- the old builders fail closed by default;
- the reviewed VPS runner source rejects old backhaul/relay phases;
- regression tests prove the freeze and the new direction;
- Ops records exact SLO, threat and rollback boundaries;
- no runtime, DNS, route, port or tracker setting changed.
The future Core-channel slice is accepted only when:
- Synology has no new public listener or router mapping;
- a Core client without the exact identity cannot connect;
- an Edge with an unknown/revoked identity is rejected by Core;
- the Edge cannot reach DSM, Docker, Hub, Engine, Foundry, PostgreSQL, MacBook
or LAN targets;
- unknown/oversized/replayed envelopes fail closed;
- Core loss causes no tracker PACKAGE acknowledgement after the deadline;
- reconnect and deduplication preserve at-least-once behavior;
- secrets and unrestricted identifiers are absent from artifacts, logs, Ops,
metrics and MCP;
- rollback stops the successor channel, restores the closed-port predecessor
and preserves Device Plane DB, Gelios, Foundry and Engine.
## Rollback
Phase 0 changes only source and Ops; rollback is a source revert with no runtime
effect.
The future transport apply owns an automatic rollback partition containing the
new Edge/Core channel units, configs, certificate references, firewall entries
and source publication. Rollback must:
- stop and disable only the candidate channel units;
- remove only candidate firewall rules/listeners;
- restore the exact accepted predecessor files and unit states;
- keep public B2/9921 closed unless it was already an accepted predecessor;
- preserve Device Core/PostgreSQL, Gateway Core, Hub, Engine, Foundry and
Gelios;
- preserve audit evidence and record the failed generation;
- never restore or invent the superseded VPS-initiated SSH backhaul.
## Consequences
The Edge contains protocol-session code and can be replaced independently. A
VPS compromise can disrupt or falsify its tracker observations and can attack
connected trackers, but it does not become a path into the private platform.
The design deliberately accepts temporary telemetry unavailability when Core
is unreachable instead of acknowledging data into an untrusted, non-durable
VPS buffer. This is the correct failure mode for the stated trust boundary.
@@ -1,114 +0,0 @@
# Device Edge B2 public pilot runbook
> Frozen historical runbook — 2026-08-10
>
> Do not configure these NAT rules, Mini relay, DNS changes or tracker route.
> This path is superseded by
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. The public pilot will receive
> a new runbook only after the Core-initiated mTLS channel and VPS Edge pass
> their separate security/deploy acceptance.
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.
@@ -1,898 +0,0 @@
# NDC Device Manager / Direct ARUSNAVI B2 / VPS
> Historical audit notice — 2026-08-10
>
> Live evidence in this document remains useful, but the product and transport
> decisions are superseded by `DCPLATFORM-76` and
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Device Core is a standalone
> Hub application, not a Foundry Page; the Edge contains a bounded adapter
> session role; Synology/Core initiates the private channel to the VPS; the old
> VPS-to-Synology relay/backhaul must not be deployed.
Актуализированный архитектурный аудит и план перехода на Direct-primary с Gelios read fallback.
Дата фиксации: 6 августа 2026 года.
## 1. Итоговое решение
Целевая read-архитектура зафиксирована так:
- ARUSNAVI B2 отправляет данные параллельно в два monitoring server slot;
- прямой route B2 → NODE.DC становится основным источником позиции и текущей телеметрии;
- Gelios остаётся постоянно работающим legacy/read-only кандидатом для fallback;
- при недоступности прямого потока переключение выполняется отдельно для каждого устройства, а не глобально по одному health endpoint;
- наружу публикуется один канонический факт на один трайк; два источника не пишут одновременно в одну current/history projection;
- identity трайка, существующие `sourceId`, joins Foundry и Timescale-инфраструктура сохраняются;
- команды через Gelios не используются;
- команды через Direct B2 не входят в текущий этап и остаются выключенными;
- Mac Mini окончательно исключён из production ingress; его артефакты остаются историческим прототипом;
- VPS выполняет только публичный L4 ingress и зашифрованный backhaul. На нём нет БД, Engine, EDP, Foundry, Device Control Core, decoder, ACK-логики или command transport.
Важная терминологическая поправка: B2 приходит на VPS не HTTP-пакетами, а сырым TCP-потоком `INTERNAL`. HTTP/HTTPS может использоваться только внутри закрытого контура после Gateway/decoder. Шифрование начинается на плече VPS → private NODE.DC contour; первый hop B2 → VPS остаётся обычным raw TCP, если сам B2 не поддерживает иной транспорт.
## 2. Что проверено live
Этот документ опирается не только на Ops, но и на live MCP и текущий source:
- Ops: проекты `NDC PLATFORM` и `ROBOT2B`, полные карточки и комментарии;
- Engine: granted L2 targets, графы, runtime, executions, credential binding status, output profiles и telemetry catalog;
- Foundry: application instance, Map page, bindings, profiles и server-owned consumer progress;
- Ontology: live catalog, entities, relations и guardrails;
- source: Device Plane, ARUSNAVI adapter, Gateway, Control Core, EDP writer/reader scope и deployment artifacts;
- DNS: текущий A-record;
- SSH: доступные локальные aliases и наличие однозначной VPS identity.
Ops в этом аудите является журналом решений и статусов. Реализация считается подтверждённой только там, где она совпадает с live MCP, runtime или source.
## 3. Что в исходном аудите устарело
### 3.1 MCP уже работает
Исходный текст говорил, что Ops, Engine, Ontology и Foundry MCP недоступны. На момент этой актуализации все четыре live boundary доступны и прочитаны.
### 3.2 Канонический продукт — v5, не v3
Production Map получает:
```text
fleet.positions.current.v5@5.0.0
ontology.map.moving_object.v3
delivery = snapshot+patch
history = latest observation / 60 s bucket
retention = 90 days
```
Определение продукта находится в `platform/services/external-data-plane/definitions/fleet.positions.current.v5.json`.
### 3.3 Device Manager Page ещё не существует
Live Page Library Foundry содержит только `map@0.1.0`. Канонического шаблона `Device Manager`, server-owned `device-plane-control` provider и соответствующего UI сейчас нет.
Device Manager не блокирует Direct ingestion: текущий этап можно завершить через Device Plane, Engine/EDP и существующую Map. UI управления устройствами остаётся отдельной более поздней фазой.
### 3.4 VPS проверен live по SSH
После получения точной SSH identity выполнен read-only аудит `root@155.212.211.15`. Изменений на host не выполнялось.
Проверенная identity:
- hostname `koffyvngij`;
- KVM/QEMU;
- Ubuntu 24.04.4 LTS, kernel `6.8.0-137-generic`;
- SSH host key уже был pinned локально; ED25519 fingerprint `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w`.
- используемый client key имеет mode `0600` и fingerprint `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw`;
- в `/root/.ssh/authorized_keys` находятся два unrestricted key lines: этот Mac key и отдельный provider `beget-access-key`; их дальнейшая судьба должна быть explicit management-access policy, а не ручное удаление во время аудита.
Проверенная ёмкость:
- 1 vCPU;
- 961 MiB RAM, около 621 MiB available во время аудита;
- swap отсутствует;
- root filesystem 8.7 GiB, 2.3 GiB used, 6.4 GiB available;
- inode usage 11%;
- system clock synchronized, NTP active, timezone UTC;
- failed systemd units отсутствуют;
- journal занимает 9.9 MiB.
Проверенная сеть/runtime:
- `eth0` имеет public `155.212.211.15/32`, default route через `100.100.1.1` on-link;
- наружу слушает только SSH на `0.0.0.0:22` и `[::]:22`; `9921/TCP` не слушает;
- Docker, Podman, containerd, Tailscale, WireGuard и relay отсутствуют;
- Fail2Ban активен для `sshd`; unattended upgrades и time sync активны.
Текущий security baseline не принят для production:
- UFW inactive;
- nftables/iptables INPUT policy `ACCEPT`; единственное правило — Fail2Ban reject set для SSH;
- `PermitRootLogin yes`;
- `PasswordAuthentication yes`, root password установлен;
- `X11Forwarding yes`;
- `AllowTcpForwarding yes`, `PermitOpen any`, `DisableForwarding no`.
Следовательно, VPS подходит по мощности для одного bounded relay и encrypted backhaul, но public `9921` нельзя включать до canonical firewall/SSH/runtime bootstrap и rollback acceptance.
### 3.5 Домен сейчас указывает не на VPS
`device.nodedc.ru` на 6 августа 2026 года резолвится в `95.165.91.235`. По `DCPLATFORM-34` это внешний адрес Synology/DSM, а не подтверждённый адрес нового VPS.
`device.dc.ru`, упомянутый устно, A-record не вернул. Каноническое имя в архитектуре — `device.nodedc.ru`.
До VPS acceptance и DNS cutover нельзя настраивать B2 на текущий `device.nodedc.ru:9921`: raw ingress на Synology запрещён.
### 3.6 Полный IMEI уже существует в restricted Foundry contour
Live Foundry получает `device_imei` в двух restricted bindings:
- `fleet.units.contacts.current.v1`;
- `fleet.units.identity.current.v1`.
Карточка объекта показывает полный IMEI как `restricted` text field из aspect `unit_identity`. Это не public/unrestricted поле, но это и не маскированное значение.
Новая принятая политика:
- IMEI не является паролем или secret;
- он может проходить внутри NODE.DC contour и использоваться для сопоставления устройства;
- он остаётся classified `restricted` identifier;
- его нельзя без необходимости писать в Ops, обычные логи, публичные payload, metrics labels или unrestricted MCP output;
- VPS видит IMEI только как часть непрозрачного TCP stream и не должен его извлекать или логировать;
- основной lookup в Device Plane может оставаться HMAC digest; полный IMEI разрешён в restricted projection там, где он действительно нужен.
IMEI по-прежнему не является доказательством ownership. Он является идентификатором для lookup/crosswalk, а claim/tenant assignment остаются административным действием.
## 4. Фактическая production read-цепочка
```mermaid
flowchart LR
B2["ARUSNAVI B2"] --> GELIOS["Gelios monitoring route"]
GELIOS --> L2["Engine alxvw4fn\nGELIOS — REALTIME POSITIONS"]
L2 --> EDP["EDP / Timescale\nfleet.positions.current.v5"]
EDP --> FOUNDRY["Foundry\nRobot2B Trike Positions"]
B2 -. "Direct route пока не подключён" .-> VPS["VPS Device Edge\nfoundation accepted"]
VPS -. "backhaul/relay gated" .-> GW["Synology Device Gateway"]
GW -. "PACKAGE сейчас ACK + discard" .-> STOP["Нет decoder / EDP publish"]
```
### 4.1 Live Engine
Production owner позиции и телеметрии:
- L1 workflow: `WCb62yGL8v`;
- target: `alxvw4fn`;
- name: `GELIOS — REALTIME POSITIONS`;
- revision: `68f4c852b8bc25b99c40b22fb4ca724fa6a0a34adbff74d1224b12ca7087bad8`;
- runtime workflow: `RupsyGGlawBzmUFm`;
- runtime active;
- schedule: каждые 10 секунд;
- рабочий request: `GET /api/v1/units` с `incltrip=true`, `inclcntrs=true`, `inclsnsrs=true`, `incllsv=true`;
- writer bindings и Gelios rotating credential зарегистрированы со status `ok`.
Во время проверки executions завершались успешно примерно за 5–10 секунд. Execution `1297364` был `success` и дал структурный профиль 107 subjects.
Normalized fact содержит:
- `sourceId = gelios-unit-<provider unit id>`;
- `semanticType = map.moving_object`;
- geometry Point;
- `display_name`;
- `position_source = gelios`;
- `signal_state`;
- `movement_state`;
- speed/course/elevation;
- satellite count, HDOP/accuracy при наличии;
- mileage, engine hours;
- `sensor_readings`.
Live telemetry catalog execution `1297364` подтвердил 17 безопасных reading IDs без rejected/unsupported values:
```text
sensor.param.call_btn
sensor.param.gps_mod
sensor.param.gsm
sensor.param.gsm_level
sensor.param.gsm_st
sensor.param.gyro
sensor.param.ign_virt
sensor.param.in_0
sensor.param.in_1
sensor.param.in0
sensor.param.in1
sensor.param.nav_st
sensor.param.pwr_ext
sensor.param.pwr_int
sensor.param.sim1_st
sensor.param.sim2_st
sensor.param.v_in
```
`in_0` и `in_1` имеют provider-configured label conflict; это уже отражено в telemetry catalog и не должно скрываться при Direct comparison.
### 4.2 Инертный duplicate target
Target `g7q86421` (`gelios.positions.current.realtime.v7`) не является вторым production writer.
В его live graph соединены только:
```text
manual trigger
→ monitoring config
→ units request
→ extraction
→ ontology map
```
Scheduled trigger и `NDC Data Product Publish • fleet.positions.current.v5` физически отсоединены. Schedule всё ещё создаёт короткие успешные пустые executions примерно раз в две минуты, но публикации не выполняются.
Это подтверждает текущий правильный инвариант: у `fleet.positions.current.v5` один активный producer path.
### 4.3 Engine platform debt
При будущей переработке L2 нельзя игнорировать открытые карточки:
- `DCPLATFORM-72`: UI `node.parameters` и executable `data.n8n.parameters` могут расходиться;
- `DCPLATFORM-73`: managed writer не всегда корректно перепривязывается к новой graph revision без revoke/recreate.
Любое изменение arbiter graph требует post-write graph equality, deep validation, свежего execution и exact writer acceptance. Успешный patch preview сам по себе недостаточен.
## 5. Live Foundry
Application:
- ID: `1c7dcdbb-6e50-4272-b1bc-aa5ece77ae99`;
- name: `Robot2B Trike Positions`;
- slug: `robot2b-trike-positions`;
- status: `draft`;
- version: `0.1.0`;
- одна page `map` на `/`;
- Map template `map@0.1.0`;
- updated: `2026-08-05T17:29:28.077Z`.
Live server-owned consumers:
| Binding | Product | Subjects | Cursor | Last error |
|---|---|---:|---:|---|
| `trike-current-positions` | `fleet.positions.current.v5` | 107 | 124720 | null |
| `trike-unit-profile` | `fleet.units.profile.current.v1` | 107 | 74 | null |
| `trike-unit-contacts` | `fleet.units.contacts.current.v1` | 107 | 55 | null |
| `trike-unit-identity` | `fleet.units.identity.current.v1` | 107 | 3651 | null |
| `depttrans-pmd-slow-zones` | `map.zones.current.v2` | 903 | 10 | null |
Все consumers включены и используют `target-scoped-server-only` reader grant. Positions consumer получил свежий patch во время аудита.
Существующее представление уже provider-neutral по renderer и composition:
- один primary moving-object binding;
- profile/identity joins выполняются по стабильному `sourceId`;
- в Data tab уже показываются `sourceId`, `semanticType`, `position_source`, `dataProductId`, `receivedAt`;
- отдельный Direct pin или отдельная Direct Map не нужны.
Чего нет:
- source-selection mode `primary|fallback|shadow|stale`;
- причина выбора источника;
- последнее Direct и последнее Gelios observation одновременно;
- source health/freshness;
- source badge/facet/counter;
- Arnavi/Direct contract;
- Device Manager Page.
Consumer policy v5 имеет `freshness=none`, `staleAfterMs=null` и `staleTransitions=0`. Следовательно, Foundry сам не определит падение VPS: selection и freshness должны приходить из server-owned upstream contract.
Отдельная визуальная деталь: сохранённый state `trike-current-positions` имеет `visible=true`, но filters `movement_state=[]` и `signal_state=[]`. По контракту Foundry пустой массив matches nothing. Это может объяснять пустую Map при наличии 107 subjects. Состояние не изменялось в ходе аудита; перед visual acceptance надо отдельно подтвердить, намеренно ли сохранён explicit empty view.
`trike-unit-contacts` выглядит избыточным: subject detail profile его не использует, а нужные IMEI/contacts уже присутствуют в `unit_identity`. Удалять binding до отдельного UI review не следует.
## 6. Live Ontology
Live catalog:
- hash: `cdf3f5310359cb36`;
- 204 entities;
- 183 relations;
- 143 aliases;
- 46 guardrails;
- 41 blocked conflations.
Provider-neutral каркас уже существует:
```text
integration.provider
integration.connection
integration.collection_profile
integration.collection_run
integration.raw_envelope
integration.canonical_subject
integration.read_model
integration.realtime_channel
map.moving_object
map.state_facet
```
Поэтому не нужен новый renderer, новая Map, новый сайт или отдельная пользовательская сущность `Arnavi trike`.
Но семантический разрыв реальный:
- tracker entity есть только как `gelios.tracker_device`;
- IMEI/device identifier не определён provider-neutral;
- Arnavi/ARUSNAVI provider package отсутствует;
- source selection/failover semantics отсутствуют;
- `gelios.telemetry_snapshot`, `gelios.signal_state` и `gelios.movement_state` имеют Gelios-specific authority.
Нельзя публиковать Direct B2 под видом Gelios mapping. Особенно нельзя молча объявить Direct `signal_state` результатом Gelios monitoring-config или добавить `fallback/stale` в закрытые `active|inactive` / `moving|stopped` enum.
Минимальное canonical изменение — не новая бизнес-сущность, а:
1. source-evidenced ARUSNAVI B2 provider/mapping package;
2. provider-neutral policy для выбранного источника и freshness;
3. нейтральное определение `signal_state`/`movement_state` для successor product;
4. restricted device identifier/crosswalk semantics по открытой `DCPLATFORM-70`.
## 7. Текущий Direct B2 код
### 7.1 Что реализовано
ARUSNAVI adapter реализует:
- HEADER2 `FF 23`;
- 8-byte little-endian IMEI;
- 15-digit validation;
- PACKAGE boundaries;
- packet length и checksum;
- HEADER acknowledgement с Unix time;
- package-number acknowledgement;
- bounded frame/buffer limits;
- commands disabled.
Основные source anchors:
- `platform/device-plane/packages/arusnavi-b2-adapter/src/index.mjs:56` — HEADER2;
- `.../index.mjs:93` — HEADER ACK;
- `.../index.mjs:110` — PACKAGE framing/checksum;
- `.../index.mjs:187` — PACKAGE ACK;
- `platform/device-plane/services/device-gateway/src/runtime.mjs:144` — session state machine.
Device Plane test suite: 41 passed, 0 failed.
### 7.2 Критический ACK/data-loss gap
`tryParseB2Package()` возвращает только:
```text
bytesConsumed
packageNumber
packetCount
```
Он не возвращает packet data или decoded tags.
Gateway после успешного parse:
```text
buffer = buffer after package
ACK counter++
send PACKAGE ACK
```
Package sink, durable queue, EDP publish и decoder отсутствуют. Иными словами, текущий Gateway сообщает B2 «пакет принят», после чего payload теряется.
Этот режим допустим только как discovery/framing pilot. Он непригоден для Direct-primary telemetry.
### 7.3 Реализован только quarantine observe
Core имеет только:
```text
POST /internal/v1/device-discoveries:observe
```
Он HMAC-хэширует IMEI, создаёт/обновляет quarantine discovery и отдаёт masked projection. Claim endpoint, inventory import и device CRUD отсутствуют.
Дополнительный blocker: Gateway Core client принимает только response `lifecycleState=quarantine`. Если discovery будет переведён в `claimed`, текущий client отклонит response, а HEADER ACK не будет отправлен.
### 7.4 Нет связи device → canonical trike
`device_bindings` сейчас содержит только:
```text
contour_id
target_kind
target_ref
capabilities
```
В нём нет `device_id`. В `device_instances` нет `canonical_subject_ref`.
Следовательно, из одного contour binding нельзя доказать, какой IMEI соответствует какому `gelios-unit-*`. Без explicit crosswalk Direct publisher либо создаст дубликаты, либо присвоит наблюдение неправильному трайку.
Минимальное исправление без новой доменной сущности:
- добавить `canonical_subject_ref` к существующему `device_instance`/claim contract;
- сопоставлять IMEI digest с restricted Gelios identity inventory;
- сохранять существующий `gelios-unit-*` как canonical `sourceId` на переходном этапе;
- неизвестный/duplicate IMEI оставлять unmatched quarantine;
- автоматический matcher может только предложить mapping; принятие принадлежности остаётся explicit admin action.
## 8. Почему Mini artifacts нельзя применить к VPS
Mini deployment жёстко фиксирует:
- runtime host `ndcmini12`;
- NIC `enp1s0f0`;
- IPvlan `192.168.71.253`;
- LAN `192.168.68.0/22`;
- gateway `192.168.68.1`;
- Mini host `192.168.68.54/22`;
- Amnezia routes;
- userspace Tailnet SOCKS container;
- Deco/double-NAT topology.
Эти assumptions находятся в:
- `docker-compose.device-edge.ingress.yml`;
- `deployment/device-edge-ingress-ipvlan-v1.json`;
- `deployment/device-edge-admission-gate-v1.json`;
- `deployment/device-edge-backhaul-v1.json`;
- `infra/deploy-runner/build-device-edge-ingress-artifact.mjs`;
- `infra/deploy-runner/nodedc-edge-deploy`;
- `docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md`.
Переиспользовать можно relay implementation и fail-closed limits. Переиспользовать Mini Compose overlay, descriptor, builder или runner нельзя.
VPS требует отдельный versioned placement/transition того же логического component `device-edge`, с собственными:
- exact host identity и predecessor;
- public interface/bind;
- firewall contract;
- Tailscale/WireGuard route;
- SSH backhaul trust;
- resource limits;
- acceptance;
- backup/journal/rollback.
Это additive extension `DCPLATFORM-21`, а не ручной `docker compose up` и не ослабление канона.
## 9. Целевая архитектура
```mermaid
flowchart TB
B2["ARUSNAVI B2\nдва server slot"]
B2 -->|"INTERNAL raw TCP"| GELIOS["Gelios legacy read route"]
B2 -->|"INTERNAL raw TCP :9921"| VPS["VPS Device Edge\nopaque relay only"]
VPS -->|"encrypted private backhaul"| GW["Synology Device Gateway\nframing + decoder + ACK owner"]
GW --> CORE["Device Control Core\nclaim + canonical subject crosswalk"]
GW -->|"after decode and durable acceptance"| DIRECT["Direct candidate Data Product\nworking contract"]
GELIOS --> GL2["Existing Gelios L2 alxvw4fn"]
GL2 --> V5["fleet.positions.current.v5\nGelios candidate + legacy history"]
DIRECT --> ARB["Server-owned per-device arbiter"]
V5 --> ARB
ARB --> V6["fleet.positions.current.v6\none canonical writer"]
V6 --> MAP["Existing Foundry Map binding\nsame page/profile/sourceId"]
CORE -. "future, separate red domain" .-> CMD["Commands disabled"]
```
### 9.1 Что остаётся существующим
- Device Plane PostgreSQL/Core/Gateway на Synology;
- EDP/Timescale;
- Robot2B L1;
- Gelios collector `alxvw4fn`;
- `fleet.positions.current.v5` как legacy candidate и immutable pre-cutover history;
- Foundry application, page, presentation profile и detail profile;
- `sourceId = gelios-unit-*` как переходная canonical identity;
- profile/identity products и joins;
- Gelios route на B2.
### 9.2 Какие технические контракты неизбежно добавляются
«Без новых сущностей» достижимо на уровне продукта/UI: не нужны новый сайт, новый backend, новая DB, новая Map или второй трайк.
Но полностью без новых технических контрактов нельзя:
- нужен Direct candidate product или другой durable server-owned candidate store;
- нужен ARUSNAVI provider/mapping package;
- нужен один canonical arbiter writer scope;
- нужен successor `fleet.positions.current.v6`, потому что v5 не разрешает поля selection/fallback provenance и имеет старую семантическую authority;
- нужен exact device → canonical subject field/claim contract.
Это versioning и integration configuration, а не новые пользовательские domain objects.
### 9.3 Где должен жить arbiter
Не на VPS и не в Foundry.
Engine подходит для чтения candidate products, нормализации и orchestration. EDP должен оставаться durable state и единственным publish authority. Практический вариант:
1. Gelios продолжает писать v5.
2. Gateway/Direct adapter пишет отдельный candidate product после durable acceptance.
3. Arbiter L2 читает оба products отдельными managed reader grants.
4. Arbiter читает предыдущий canonical state для hysteresis или хранит selection state в каноническом fact.
5. Только arbiter имеет writer grant на v6.
Существующий `g7q86421` можно рассмотреть как runtime slot для arbiter только после neutral rematerialization. Сейчас он Gelios-scoped; публиковать Direct под его Gelios connection нельзя. Если Engine не позволяет безопасно сменить connection authority, нужен новый служебный L2 target внутри существующего Robot2B L1. Это не новая бизнес-сущность.
### 9.4 Правило выбора источника
Выбор выполняется по каждому `canonical_subject_ref`.
Direct eligible, только если одновременно выполнены:
- device claimed и crosswalk однозначен;
- HEADER/PACKAGE framing и checksum валидны;
- telemetry packet decoded по versioned ARUSNAVI contract;
- observed time валиден и не уходит недопустимо в будущее;
- observation монотонно либо явно допустимо out-of-order;
- EDP candidate receipt durable;
- direct observation свежее per-device threshold;
- Gateway/backhaul не сообщает terminal fault.
Fallback:
```text
direct stale or invalid
→ grace period
→ select latest valid Gelios observation
→ selection_mode=fallback
```
Возврат:
```text
direct returns
→ shadow only
→ N consecutive valid observations / accepted time window
→ identity and timestamp checks pass
→ selection_mode=primary
```
Точные grace/N/timeout не следует угадывать. Их надо вывести из pilot packet cadence и shadow statistics.
Первый arbiter должен выбирать цельный position+telemetry observation атомарно. Не следует молча смешивать координаты Direct с sensor readings Gelios в одном fact. Cold profile и restricted identity могут временно продолжать приходить из Gelios отдельными joined aspects.
### 9.5 Provenance successor product
Рабочий набор полей v6:
```text
position_source
telemetry_source
source_selection_mode
source_selection_reason
selected_observed_at
direct_last_observed_at
gelios_last_observed_at
selection_changed_at
```
Точные field IDs и enum должны сначала пройти Ontology/Data Product authority. `fallback` нельзя перегружать в `signal_state`.
Для визуальной совместимости можно сохранить enum:
```text
signal_state = active | inactive
movement_state = moving | stopped
```
Но v6 должен определить их как neutral Robot2B policy. Direct `signal_state` выводится из свежести direct observation, а не из Gelios monitoring-config. Movement threshold, если сохраняется `speed > 2`, должен быть явно принят как platform policy, а не назван «нативным B2 статусом».
### 9.6 История
Timescale/Postgres остаётся тем же сервисом и volume, но история EDP scoped по:
```text
tenant + connection + provider + dataProductId + sourceId
```
Поэтому смена writer connection/product не создаёт автоматическую бесшовную history query через старые v5 rows.
Правильный cutover:
- v5 history остаётся immutable legacy history до даты переключения;
- v6 пишет новую canonical history с тем же `sourceId`;
- `NDC Robot2B History and Reports` получает version-aware read: v5 до cutover, v6 после cutover;
- никакой второй Timescale DB и никакая destructive migration не создаются.
## 10. Durable ACK contract
Production Direct path должен иметь такой порядок:
```text
HEADER2
→ resolve/create discovery
→ resolve claimed device and canonical subject when available
→ HEADER ACK
PACKAGE
→ validate length/checksum
→ decode packet tags
→ normalize candidate observation
→ idempotent durable EDP acceptance
→ PACKAGE ACK
```
Если durable acceptance недоступен, PACKAGE ACK не отправляется, и B2 получает возможность повторить пакет.
Idempotency нельзя строить только по package number: диапазон ограничен и номер переиспользуется. Candidate key должен включать device identity, session/package context и digest подтверждённых bytes/packet timestamp. Raw payload не обязан сохраняться; можно сохранять digest и normalized facts.
Для quarantine pilot допустим отдельный режим framing proof: validated PACKAGE ACK после безопасного quarantine evidence без заявления, что телеметрия сохранена. Этот режим должен быть явно отличим от production telemetry acceptance.
Gateway, а не VPS, остаётся ACK owner.
## 11. VPS contract
### 11.1 Runtime role
VPS:
- слушает public `TCP/9921`;
- применяет bounded session/rate/buffer policy;
- непрозрачно передаёт stream на private backhaul;
- держит минимальный loopback/internal health;
- не читает IMEI и telemetry;
- не ACK’ает B2;
- не хранит raw packet или DB;
- не имеет Core/EDP/Engine/Foundry credentials;
- не выполняет commands.
### 11.2 Предлагаемый минимальный placement
На слабом VPS достаточно:
- host Tailscale или WireGuard;
- `device-edge-relay` container;
- `device-edge-backhaul` container с key-only SSH local forward в Synology target;
- Docker Compose plugin;
- system firewall default-deny;
- time sync;
- bounded Docker logs;
- root-owned deploy runner и state outside artifact.
Live baseline подтверждает, что host нельзя использовать для production image builds или тяжёлого runtime. Начальные resource limits для reviewed candidate:
- relay memory limit около 192 MiB;
- backhaul около 64 MiB;
- PID limits;
- logs `10 MiB × 3`;
- image build не выполнять на production host, если 1 GiB RAM не выдерживает; использовать reviewed prebuilt/digest-pinned image или swap policy, утверждённую отдельно.
До application artifact нужен отдельный bootstrap transition, который fail-closed переводит host из текущего baseline:
- firewall INPUT `ACCEPT` → default-deny с сохранением проверенного SSH access;
- root/password/X11/unrestricted forwarding → отдельный key-only management boundary;
- установить только утверждённый container/runtime и encrypted backhaul prerequisites;
- создать versioned service identities, fixed roots, bounded logs/resources и rollback;
- не открывать `9921` в bootstrap transition.
Текущие Mini defaults `10 s session timeout` и `256 KiB per direction` являются pilot constraints. Их нельзя автоматически переносить в production B2: packet cadence и session lifetime должны быть измерены на одном реальном B2.
### 11.3 Public surface
Наружу:
- `9921/TCP` для B2;
- management SSH только key-only и максимально ограниченно, предпочтительно через Tailnet/allowlist.
Не публикуются:
- health endpoint;
- Docker API;
- UDP range;
- Core/Gateway/EDP ports;
- database;
- DSM;
- reverse proxy `443 → 9921`.
### 11.4 DNS
DNS меняется только после:
1. exact VPS identity;
2. canonical deploy-ok;
3. public listener/firewall acceptance;
4. encrypted backhaul acceptance;
5. external synthetic TCP proof;
6. rollback proof.
Только затем `device.nodedc.ru` переводится с `95.165.91.235` на VPS. Изменение B2 server slot выполняется после DNS convergence и только для одного pilot; Gelios slot не трогается.
## 12. Фазовый план
### Phase 0 — authority и точный VPS target
- точный target `root@155.212.211.15` получен;
- pinned ED25519 fingerprint проверен;
- live read-only inventory завершён;
- VPS placement зафиксирован в `DCPLATFORM-21` и `DCPLATFORM-74`;
- не менять DNS, B2 и Synology.
### Phase 1 — canonical VPS bootstrap
- отдельный VPS `device-edge` descriptor/runner;
- install Docker/Compose, runtime user, firewall, time sync, log policy, private network client;
- deploy relay/backhaul exact artifact;
- prove resource limits, ports, health, backup/journal и automatic rollback;
- terminal state только `deploy-ok`.
### Phase 2 — transport acceptance без трекера
- synthetic TCP from external host → VPS `9921` → encrypted backhaul → loopback Gateway;
- prove VPS does not parse/ACK;
- prove Synology remains non-public;
- prove closing/rollback removes only VPS `9921` and preserves Gelios;
- no DNS/B2 change yet.
### Phase 3 — один B2, quarantine shadow
- вручную добавить свободный B2 INTERNAL slot на accepted VPS address/hostname;
- Gelios slot сохранить;
- доказать HEADER2 → masked quarantine;
- доказать PACKAGE framing/checksum/ACK;
- commands remain disabled;
- не объявлять telemetry production, пока PACKAGE всё ещё discard.
### Phase 4 — decoder, claim и durable candidate
- принять точную официальную tag/framing specification для firmware pilot B2;
- parser возвращает packet data/typed records;
- реализовать explicit claim/crosswalk в существующем Device Instance contract;
- исправить Gateway/Core lifecycle для claimed devices;
- добавить Direct candidate product и managed writer;
- ACK только после durable candidate receipt;
- unknown tags fail closed или попадают в bounded classification evidence, но не в unrestricted Data Product.
### Phase 5 — shadow comparison
Для одного pilot сравнивать Direct и Gelios без canonical switch:
- identity match;
- observation timestamps и clock skew;
- coordinate distance;
- speed/course/elevation;
- satellites/HDOP/accuracy;
- mileage/engine hours;
- все 17 текущих telemetry reading IDs;
- missing/extra tags;
- duplicate/retry/out-of-order behavior;
- reconnect cadence и session lifetime.
До этой фазы утверждение «Gelios отдаёт ровно те же B2 пакеты» не считается доказанным. Сейчас доказано лишь, что Gelios предоставляет данные, относящиеся к B2 units, и текущий normalized surface совпадает с ожидаемым B2 профилем. Exact packet equivalence требует Direct shadow.
### Phase 6 — arbiter и v6
- neutral Ontology/source-selection contract;
- per-device eligibility, grace и hysteresis;
- один arbiter writer;
- successor v6;
- history cutover policy;
- no field-level silent mixing;
- simulate Direct failure and recovery;
- Gelios continues collecting at all times.
### Phase 7 — Foundry cutover
- существующий `trike-current-positions` binding переводится на v6;
- page, binding ID, presentation/detail profiles и sourceId сохраняются;
- Data tab получает selection provenance;
- optional source badge/facet добавляется только по versioned field contract;
- решить explicit empty filters;
- проверить все joins profile/identity и 107 subjects;
- rollback возвращает binding на v5.
### Phase 8 — эксплуатационная приёмка
- sustained Direct-primary window;
- controlled VPS/backhaul/Gateway failure → per-device Gelios fallback;
- controlled recovery → shadow → Direct promotion;
- no duplicate current facts/history;
- no timestamp regression;
- bounded logs/resources;
- alerts/metrics без full IMEI;
- documented rollback.
### Phase 9 — команды, отдельная работа
Только после принятого Direct read path:
- official command contract;
- draft/plan/confirm/queue/dispatch/ACK/reconciliation;
- immutable audit и idempotency;
- no automatic Gelios command fallback;
- no blind retry after unknown outcome.
## 13. Ops reconciliation
Фактическое значение карточек после аудита:
- `DCPLATFORM-74` — master architecture; Mini placement устарел, VPS должен стать новым accepted Device Edge placement;
- `ROBOT2B-5` — старый pause superseded: Direct track возобновлён, SSH access подтверждён, но работа пока blocked на canonical VPS bootstrap/backhaul и Direct data path gaps;
- `ROBOT2B-6` — current production Gelios baseline; теперь это сохраняемый legacy candidate/fallback, а не конечная архитектура;
- `DCPLATFORM-21` — authority для отдельного VPS runner/artifact/rollback;
- `DCPLATFORM-70` — provider-neutral device/identifier/crosswalk debt;
- `DCPLATFORM-72` и `DCPLATFORM-73` — обязательные Engine change acceptance risks.
Актуализация записана в Ops 6 августа 2026 года:
- `DCPLATFORM-74`: comment `fcdb49c3-8cfc-4c94-8e67-137de14ec9f2`;
- `ROBOT2B-5`: comment `eb6284b5-4d0e-4064-8eda-659485b94115`, карточка переведена из Backlog в In Progress;
- `ROBOT2B-6`: comment `5fecce6e-fec0-4119-814e-0e2bb7a6a41e`;
- `DCPLATFORM-21`: comment `c9bbe692-d301-4127-8734-9c1e91bb5083`.
После live SSH-аудита VPS добавлены уточнения:
- `DCPLATFORM-74`: comment `fa26ac68-17c2-4d56-8b6b-f483b1275739`;
- `DCPLATFORM-21`: comment `8db0d7e2-1bb4-4390-93e3-aebb68429a1b`;
- `ROBOT2B-5`: comment `750aeab7-b2eb-4ea1-bb4a-e6e098c5a02c`.
Ops не должен утверждать, что:
- Direct telemetry уже принимается;
- VPS relay/bootstrap уже развёрнут и принят;
- `device.nodedc.ru` уже указывает на VPS;
- Device Manager Page существует;
- IMEI полностью отсутствует в Foundry;
- текущий Gateway выполняет durable PACKAGE acceptance;
- Gelios и Direct уже переключаются автоматически.
## 14. Текущие блокеры
1. VPS firewall/SSH baseline не hardened и не принят canonical runner-ом.
2. Не выбран и не enrolled exact encrypted VPS → private NODE.DC backhaul identity/target.
3. Current DNS указывает на Synology.
4. Mini deploy artifacts не переносимы на VPS.
5. Gateway ACK’ает и отбрасывает PACKAGE.
6. Нет telemetry tag decoder.
7. Нет claim endpoint и claimed-device handshake.
8. Нет device → canonical trike crosswalk.
9. Нет Direct candidate product/writer.
10. Нет neutral source-selection ontology/product contract.
11. Нет arbiter и successor product/history cutover.
12. Foundry source provenance минимальна, а saved filters могут скрывать все subjects.
13. Команды намеренно выключены.
## 15. Следующее разрешённое действие
Следующий шаг — зафиксировать exact encrypted backhaul choice/identity и подготовить additive VPS deployment domain: root-owned runner, deterministic bootstrap artifact, exact predecessor, firewall/SSH acceptance и rollback. Первый bootstrap не открывает `9921`. Никакой B2, DNS или production Map mutation до отдельного transport acceptance не требуется.
## 16. Implementation update — 2026-08-06
Этот раздел заменяет устаревшие operational assertions в разделах 3.4, 11.2,
12 Phase 01, 13–15. Архитектурные выводы остальных разделов сохраняются.
Реализовано:
- созданы отдельные Ops cards `DCPLATFORM-75` и `ROBOT2B-7`;
- создан отдельный root-owned VPS deploy domain
`/usr/local/sbin/nodedc-b2-vps-deploy`;
- Docker на 961 MiB VPS сознательно не устанавливался;
- foundation `device-edge-vps-foundation-20260806-003` принят с `deploy-ok`;
- SSH переведён в key-only, nftables — в default-deny, публично только TCP/22;
- Node.js 22.23.2 и Tailscale 1.102.2 установлены из pinned static archives;
- Tailscale runtime user `nodedc-edge` и отдельный ED25519 backhaul key созданы
runner-ом; isolation-aware backhaul/relay transitions используют отдельные
`nodedc-backhaul` и `nodedc-relay`;
- default wildcard tailnet grant удалён; VPS переведён из user ownership в
`tag:device-edge-vps` с единственным egress `100.109.216.21:2222/tcp`;
- отрицательные проверки с VPS подтвердили запрет Synology SSH/DSM, MacBook и
второго edge-узла;
- TCP/9921 остаётся закрыт;
- backhaul и relay artifacts собраны и стадированы, но не применены до
predecessor acceptance;
- public VPS key стадирован в Synology enrollment;
- marker-only Synology key-rotation artifact и runner candidate стадированы.
Текущие blockers/gates:
1. Root на Synology должен promote/verify exact runner, review plan и выполнить
exact enrollment apply.
2. После этого VPS runner может принять backhaul, затем relay.
3. DNS и B2 routes остаются неизменными до отдельного transport pilot.
4. Provider recovery key `beget-access-key` — RSA-1024; его отзыв требует
отдельного owner decision.
Полный повторяемый manual и release evidence находятся в
`device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md`.
@@ -1,287 +0,0 @@
# Device Plane Implementation Baseline
> Superseded topology notice — 2026-08-10
>
> The historical Foundry-Page product boundary, Mini ingress, VPS-initiated
> Tailscale/SSH backhaul and `device.nodedc.ru` raw-TCP assumptions below are
> retained only as implementation history. They must not be used for a new
> plan/apply. The accepted successor is
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`: Device Core is a standalone
> Hub application, Synology/Core initiates a mutually authenticated full-duplex
> channel to the VPS, and `device.nodedc.ru` remains the HTTPS UI surface.
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.
@@ -1,539 +0,0 @@
# Robot2B B2 VPS — конфигурационная Библия
> Замороженная историческая конфигурация — 10.08.2026
>
> Не применять описанные ниже `backhaul`, `relay`, Tailscale/SSH LocalForward,
> Synology enrollment или открытие `9921`. Целевой транспорт заменён на
> Core-initiated mTLS full-duplex channel по
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Документ сохраняется как
> evidence уже выполненных экспериментов и текущего predecessor VPS.
Статус документа: живой manual реализации от 2026-08-06.
Контур: Robot2B / NODE.DC Device Plane.
VPS: `155.212.211.15`, hostname `koffyvngij`.
## 1. Назначение
VPS является минимальным публичным Device Edge для трекеров Arnavi B2. Он не
владеет бизнес-логикой, протоколом, БД, карточками устройств, визуализацией или
командами. Его единственная целевая функция — принять ограниченный поток raw TCP
на `155.212.211.15:9921`, не интерпретируя пакет, и передать его по шифрованному
private backhaul в уже существующий `device-gateway` NODE.DC.
Gelios остаётся отдельным legacy-источником. Эта конфигурация не меняет Gelios,
DNS `device.nodedc.ru`, маршруты B2 или command transport.
```mermaid
flowchart LR
B2["Arnavi B2 trackers"]
VPS["Device Edge VPS\n155.212.211.15:9921"]
TS["Tailscale userspace\nSOCKS5 127.0.0.1:1055"]
SSH["SSH local forward\n127.0.0.1:19921"]
TARGET["Synology device-backhaul-target\n100.109.216.21:2222"]
GW["device-gateway\n127.0.0.1:9921"]
CORE["device-control-core + PostgreSQL"]
FOUNDRY["Foundry / visualization"]
GELIOS["Gelios legacy source"]
B2 -->|"raw TCP, planned route"| VPS
VPS --> TS -->|"grant: tag:device-edge-vps → TCP/2222 only"| SSH --> TARGET --> GW --> CORE --> FOUNDRY
GELIOS -->|"independent legacy ingest"| CORE
```
## 2. Красные границы
- На VPS нет базы данных и durable telemetry storage.
- VPS не разбирает Arnavi/B2 protocol и не принимает решения по IMEI.
- IMEI остаётся claimed identifier, а не доказательством владения устройством.
- Неизвестное устройство должно попадать в quarantine/discovery lifecycle.
- Command transport отключён. Команды через VPS или Gelios не вводятся.
- Gelios не выключается и не перетирается новым потоком.
- На Synology не публикуется Docker-порт `2222`; доступ только через private
Tailscale Serve.
- Public VPS не является пользовательским tailnet-узлом. Его service identity
`tag:device-edge-vps` может инициировать только TCP к `100.109.216.21:2222`.
- VPS не принимает subnet routes, DNS tailnet, exit-node capability или
Tailscale SSH. Доступ к DSM, MacBook и другим tailnet-узлам запрещён.
- Приватные ключи, auth keys, токены и пароли не входят в Git, deploy artifacts,
Ops или этот документ.
- До отдельного pilot/cutover не меняются `device.nodedc.ru` и настройки B2.
## 3. Фактический predecessor VPS
Снято до первого apply:
- Ubuntu `24.04.4 LTS`, kernel `6.8.0-137-generic`, KVM/QEMU.
- 1 vCPU, 961 MiB RAM, swap отсутствует.
- Root filesystem около 8.7 GiB; свободно около 6.4 GiB.
- `eth0`: `155.212.211.15/32`, default gateway `100.100.1.1`.
- Публично слушал только TCP/22; TCP/9921 был закрыт.
- Docker, Node.js, Tailscale и WireGuard отсутствовали.
- `fail2ban`, NTP и unattended upgrades были активны.
- UFW был inactive, nftables/iptables использовали INPUT ACCEPT.
- SSH допускал root/password и forwarding; это устранено foundation-переходом.
## 4. Канонические переходы
Конфигурация разделена на независимые data-only артефакты. Порядок нельзя
переставлять.
1. `foundation`:
- pin статических Node.js и Tailscale runtimes;
- service account `nodedc-edge`;
- key-only SSH;
- default-deny nftables, публично только TCP/22;
- отдельный ED25519 backhaul key, сгенерированный на VPS;
- userspace `tailscaled`, но без скрытого auth key;
- TCP/9921 закрыт.
2. Внешняя регистрация и сегментация `nodedc-b2-vps` в существующем tailnet:
- заменить default wildcard policy на проверяемую deny-by-default policy;
- назначить `tag:device-edge-vps`, тем самым удалить user ownership;
- разрешить тегу только `100.109.216.21:2222/tcp`;
- отрицательно проверить Synology `22/5001`, MacBook `22` и другие узлы;
- только после этого вернуть Tailscale service в состояние Running.
3. Synology `backhaul-vps-enrollment`:
- принять только публичный VPS key через enrollment;
- заменить прежний Mini key;
- пересоздать только `device-backhaul-target`;
- сохранить Device Plane, PostgreSQL, Tailscale Serve и Gelios;
- при ошибке автоматически вернуть прежний key и target generation.
4. VPS `backhaul`:
- pinned Synology host key;
- key-only SSH через Tailscale userspace SOCKS5;
- `127.0.0.1:19921 -> 127.0.0.1:9921`;
- публичный TCP/9921 всё ещё закрыт.
5. VPS `relay`:
- открыть публичный TCP/9921;
- bounded opaque relay на `127.0.0.1:19921`;
- loopback health на `127.0.0.1:18221`.
6. Отдельный pilot: изменить адрес сервера у ограниченной группы B2. Это не
часть конфигурации VPS.
## 5. Идентичности и ключи
### 5.1 Management SSH
Команда с MacBook:
```bash
ssh -i ~/.ssh/nodedc_b2_vps \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
root@155.212.211.15
```
В Ops и manual фиксируются только путь и fingerprints:
- локальный private key path: `~/.ssh/nodedc_b2_vps`;
- MacBook management public key fingerprint:
`SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw` (ED25519);
- VPS SSH server host key fingerprint:
`SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w` (ED25519).
Foundation принудительно задаёт:
- `PermitRootLogin prohibit-password`;
- `AuthenticationMethods publickey`;
- `PasswordAuthentication no`;
- `KbdInteractiveAuthentication no`;
- forwarding, agent forwarding, X11 и tunnels запрещены;
- `MaxAuthTries 3`, `LoginGraceTime 20`.
В `/root/.ssh/authorized_keys` остаются две management identity:
- MacBook ED25519 — fingerprint выше;
- provider recovery key `beget-access-key`, RSA 1024,
fingerprint `SHA256:9W1cgovqOlegteEWV0r5j4OjJgG0PGr97eVmpUBjg54`.
RSA-1024 provider key — остаточный риск. Он не удалён автоматически, потому
что отзыв внешнего recovery-доступа является отдельным необратимым решением.
После подтверждения владельца нужно либо удалить его отдельным каноническим
переходом, либо документировать как принятый break-glass access.
### 5.2 VPS backhaul client identity
Private key генерирует root-owned runner непосредственно на VPS:
- private: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519`, owner
`nodedc-edge` на foundation и `nodedc-backhaul` после backhaul apply,
mode `0400`;
- public: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519.pub`, mode `0444`;
- fingerprint:
`SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw`;
- public key:
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGUlvJ8a83qov9DHO2S/BHbVAEH4Chyc4v/DKwOIWeES nodedc-device-edge-vps-backhaul`.
В Synology staging этот public key хранится по пути:
`/volume1/docker/nodedc-device-plane/enrollment/device-edge-vps-backhaul.pub`
Private key никогда не покидает VPS.
Foundation генерирует ключ до появления публичного relay, поэтому его
временным владельцем является `nodedc-edge`. Backhaul transition атомарно
создаёт `nodedc-backhaul`, передаёт ему каталог trust и ключ и валидирует mode.
Relay запускается как третий пользователь `nodedc-relay`; он не может читать ни
private key, ни Tailscale state. Даже локальный доступ relay к SOCKS5 не расширяет
полномочия: tailnet grant разрешает только target `2222`, где аутентификация
дополнительно требует private key пользователя `nodedc-backhaul`.
### 5.3 Synology backhaul target identity
- Tailnet IP: `100.109.216.21`.
- Private SSH endpoint: `100.109.216.21:2222` через Tailscale Serve.
- User: `device-backhaul`.
- PermitOpen: только `127.0.0.1:9921`.
- Host key fingerprint:
`SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30`.
- Public host key:
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc nodedc-device-plane-backhaul-target`.
- VPS pinned known_hosts:
`/var/lib/nodedc-b2-vps/trust/backhaul_known_hosts`.
### 5.4 Tailscale service identity и grant
Живая policy сохранена 2026-08-06. Каноническая копия:
`device-plane/deployment/tailscale-device-edge-policy.hujson`.
- `autogroup:member -> autogroup:self`, все протоколы: сохраняет привычный
доступ пользователя только между собственными user-owned устройствами;
- `tag:device-edge-vps -> device-plane-backhaul`, только `tcp:2222`;
- `device-plane-backhaul = 100.109.216.21`;
- tag owner: только `autogroup:admin`;
- policy test требует accept `100.109.216.21:2222` и deny для Synology
`22/5001`, MacBook `22`, `nodedc-device-edge:22`.
Назначение тега удалило `dcctouch@gmail.com` из поля `Managed by`; живой VPS
управляется `tag:device-edge-vps`. После включения проверено с самого VPS:
- `100.109.216.21:2222` — reachable;
- `100.109.216.21:22` — blocked;
- `100.109.216.21:5001` — blocked;
- `100.114.248.4:22` — blocked;
- `100.64.19.31:22` — blocked.
Enrollment запускается с `--accept-dns=false --accept-routes=false --ssh=false`.
Runner для backhaul/relay дополнительно требует `BackendState=Running`, online
hostname `nodedc-b2-vps` и ровно один tag `tag:device-edge-vps`.
## 6. Runtime и файловая система VPS
### 6.1 Root-owned source/runtime
- runner: `/usr/local/sbin/nodedc-b2-vps-deploy`;
- live root: `/opt/nodedc-b2-vps`;
- deploy state: `/var/lib/nodedc-b2-vps-deploy`;
- inbox: `/var/lib/nodedc-b2-vps-deploy/inbox`;
- applied/failed artifacts: `applied/`, `failed/`;
- backups: `backups/`;
- journals: `state/applied.jsonl`, `state/failed.jsonl`;
- lock: `state/deploy.lock`;
- service state/trust: `/var/lib/nodedc-b2-vps`.
### 6.2 Pinned runtimes
- Node.js `22.23.2`;
archive SHA-256
`d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307`.
- Tailscale `1.102.2`;
archive SHA-256
`ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8`.
Docker не устанавливается: для 961 MiB RAM он не нужен и добавляет лишний
daemon/state surface.
### 6.3 systemd units
- `nodedc-b2-tailscaled.service`:
- user `nodedc-edge`;
- userspace networking, без TUN;
- socket `/run/nodedc-b2-vps/tailscaled.sock`;
- SOCKS5 `127.0.0.1:1055`;
- `MemoryMax=160M`;
- разрешены `AF_UNIX AF_INET AF_INET6 AF_NETLINK`.
- `nodedc-b2-backhaul.service`:
- user `nodedc-backhaul`, единственный читатель backhaul private key;
- strict host key pinning;
- local forward `127.0.0.1:19921`;
- `MemoryMax=64M`.
- `nodedc-b2-relay.service`:
- user `nodedc-relay`, без credential access;
- public `0.0.0.0:9921`;
- health `127.0.0.1:18221`;
- upstream `127.0.0.1:19921`;
- `MemoryMax=192M`.
Все units используют `NoNewPrivileges`, `ProtectSystem=strict`,
`ProtectHome=yes`, `PrivateTmp`, `PrivateDevices`, ограничение address families,
tasks и file descriptors.
## 7. Firewall и порты
Foundation nftables policy:
- input: default drop;
- loopback, established/related и ICMP разрешены;
- новый TCP/22 ограничен `30/minute`, burst `60`;
- TCP/9921 отсутствует;
- forward: default drop;
- output: accept.
Relay policy добавляет:
- drop новых соединений на TCP/9921 сверх `300/second`;
- accept TCP/9921 после rate guard.
Портовая матрица:
| Endpoint | Видимость | Владелец | Стадия |
|---|---|---|---|
| `155.212.211.15:22` | public | OpenSSH | foundation |
| `127.0.0.1:1055` | loopback | tailscaled SOCKS5 | foundation |
| `127.0.0.1:19921` | loopback | SSH local forward | backhaul |
| `127.0.0.1:18221` | loopback | relay health | relay |
| `155.212.211.15:9921` | public | bounded relay | relay |
| `100.109.216.21:2222` | tailnet grant только для VPS tag | Synology target | existing |
| `127.0.0.1:9921` на Synology | loopback | device-gateway | existing |
## 8. Relay limits
- max concurrent sessions: `128`;
- max sessions per source IP: `16`;
- max new connections/minute/source IP: `60`;
- max tracked source IPs: `4096`;
- max bytes per direction/session: `64 MiB`;
- session timeout: `300000 ms`;
- source policy: `public-ipv4-only`;
- protocol inspection: disabled на VPS;
- command transport: disabled.
## 9. Установленный foundation release
Принят 2026-08-06:
- patch: `device-edge-vps-foundation-20260806-003`;
- artifact SHA-256:
`1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a`;
- foundation-time runner SHA-256:
`3f42d23431937e70c16ce1fd346fb84a706e506ae99d89eaf11780ff1ad56c03`;
- current promoted runner SHA-256:
`5ccdc1b53ce0688e7c120976e82937842bc8491a2e05eb5f280165accfd40b6c`;
- backup:
`device-edge-vps-foundation-20260806-003-20260806-151415`;
- terminal result: `deploy-ok`;
- fresh MacBook key-only SSH acceptance: success.
Два предыдущих ID терминальны и никогда не должны применяться повторно:
- `...-001`: `tailscaled` заблокирован отсутствием `AF_NETLINK`; automatic
rollback `ok`;
- `...-002`: cloud-init `50-cloud-init.conf` опередил `90-*` и сохранил
`PasswordAuthentication yes`; automatic rollback `ok`;
- `...-003`: исправлены `AF_NETLINK` и ранний `00-nodedc-b2-vps.conf`;
acceptance успешна.
Следующие exact isolation-aware artifacts стадированы в VPS inbox и не
применяются до своих predecessor barriers:
- backhaul `device-edge-vps-backhaul-20260806-002`, SHA-256
`830750da8f9590ca4db458ec9e90f4d48ad8d1403160d3878a968b54e9eb6913`;
- relay `device-edge-vps-relay-20260806-002`, SHA-256
`305a6de769f24b2c6cee801426ec43b98a44d10e08cad75a96fd65d20b16b697`.
Версии `...-001` не применялись и recoverably перемещены из inbox в
`/var/lib/nodedc-b2-vps-deploy/withdrawn/*.superseded-by-002`, потому что в них
все три процесса использовали один Unix account `nodedc-edge`.
## 10. Synology VPS enrollment release
Стадировано, но до авторизации Tailscale и root plan/apply не считается
применённым:
- runner candidate:
`/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011`;
- runner SHA-256:
`453228c41b411d9c925091c77dc94e501f2eb3534fc241db0d2e58f0a28e12e2`;
- artifact:
`/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz`;
- artifact SHA-256:
`576dabdafde5e3b2de09c7265127928c4b463e7b40dd554fd71babc16cc70e08`;
- VPS enrollment public-key file SHA-256:
`c2718c117fd09965386524d32fa9816d9d2d9cf00b59010dcf8bcf78f29bea8c`.
Root-переход на Synology:
```bash
sudo sha256sum \
/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011
sudo install -o root -g root -m 0755 \
/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 \
/usr/local/sbin/nodedc-deploy
sudo /usr/local/sbin/nodedc-deploy verify-install
sudo /usr/local/sbin/nodedc-deploy plan \
/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz
# Apply только после review exact plan.
sudo /usr/local/sbin/nodedc-deploy apply \
/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz
```
Ожидаемый plan обязан показать:
- predecessor patch `device-plane-backhaul-target-tailnet-serve-20260804-002`;
- predecessor artifact SHA
`219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba`;
- build `none`;
- recreate только `device-backhaul-target`;
- next fingerprint `SHA256:HHTi…osJcVw`;
- public ingress disabled;
- Docker port publication disabled;
- Tailscale Serve, router/NAT/firewall, PostgreSQL и Gelios unchanged;
- automatic rollback на previous key + target recreate.
## 11. Внешняя регистрация Tailscale
На VPS auth key не хранится. Для первой регистрации root запускает:
```bash
/opt/nodedc-b2-vps/runtime/tailscale/tailscale \
--socket=/run/nodedc-b2-vps/tailscaled.sock \
up \
--hostname=nodedc-b2-vps \
--accept-dns=false \
--accept-routes=false \
--ssh=false
```
Одноразовый login URL не копируется в Ops. До включения service владелец tailnet
обязан сохранить policy и назначить tag из раздела 5.4. Backhaul plan требует
`BackendState=Running`, online hostname `nodedc-b2-vps` и exact service tag.
## 12. Сборка VPS artifacts
Из корня repository `platform`:
```bash
NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR=/tmp \
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
foundation <unique-patch-id>
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
backhaul <unique-patch-id>
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
relay <unique-patch-id>
```
Builder создаёт deterministic archive с `manifest.env`, `files.txt` и
`payload/`. В artifact запрещены `.env`, keys, trust, runtime, logs, uploads,
node_modules и symlinks. Foundation дополнительно проверяет pinned runtime
digests.
Каждый release:
1. собрать дважды и сравнить SHA-256;
2. проверить file list и отсутствие секретов;
3. скопировать exact artifact в VPS inbox;
4. выполнить свежий `plan`;
5. review границ;
6. выполнить один exact `apply`;
7. считать `deploy-ok` терминальным результатом и не запускать apply повторно.
## 13. Rollback model
VPS runner перед mutation создаёт backup exact partition и сохраняет текущий
nft ruleset и service enablement. При ошибке:
- candidate services останавливаются;
- source/config возвращаются;
- nftables и SSH config восстанавливаются;
- service enablement возвращается;
- при failed foundation удаляются созданные live/runtime roots и service user;
- при failed backhaul ключ возвращается `nodedc-edge`, а созданный
`nodedc-backhaul` удаляется;
- при failed relay созданный `nodedc-relay` удаляется;
- artifact переносится в `failed/`;
- failed ID и digest становятся терминальными.
Synology VPS enrollment отдельно сохраняет previous `authorized_keys`. При любой
ошибке он:
- удаляет candidate marker;
- атомарно возвращает прежний restricted key;
- пересоздаёт только `device-backhaul-target`;
- проверяет target, три preserved Device Plane services, Tailscale Serve и
loopback `9921`.
## 14. Развёртывание аналогичного VPS
Перед клонированием нельзя просто переиспользовать текущий artifact. Нужно
создать новый descriptor/release с новыми параметрами:
1. Получить чистый Ubuntu 24.04 LTS VPS и зафиксировать hostname, public `/32`,
gateway, CPU/RAM/disk.
2. Добавить отдельный ED25519 management key и проверить fresh connection.
3. Зафиксировать server host key fingerprint с доверенного канала.
4. Проверить active fail2ban/NTP/unattended upgrades и отсутствие listeners,
кроме TCP/22.
5. Изменить pin в runner/descriptor:
`RUNTIME_HOST`, `PUBLIC_IPV4`, management/server fingerprints и Tailscale
node name.
6. Не переносить private backhaul key: новый VPS должен сгенерировать новую
пару самостоятельно.
7. Собрать deterministic foundation с новым terminal patch ID.
8. Promoted runner проверить отдельным `verify-install`.
9. Сделать plan → review → apply.
10. Зарегистрировать новый tailnet node без сохранения auth key.
11. Скопировать только public backhaul key в новый enrollment path.
12. Выполнить отдельный Synology key rotation/grant transition.
13. Только после принятого private backhaul применять relay.
14. Только после relay acceptance перенаправлять ограниченный pilot B2.
## 15. Acceptance checklist
- [x] Foundation artifact deterministic и secret-free.
- [x] Foundation accepted с automatic rollback coverage.
- [x] Fresh key-only SSH с MacBook работает.
- [x] Public TCP/9921 не открыт на foundation.
- [x] Отдельный VPS backhaul public key создан и pinned.
- [x] Synology enrollment key, runner candidate и artifact стадированы.
- [ ] Tailnet node `nodedc-b2-vps` авторизован и `Running`.
- [x] Tailnet wildcard grant удалён; VPS tagged и negative-route tests пройдены.
- [x] Isolation-aware VPS runner promoted и `verify-install-ok`.
- [ ] Synology runner candidate promoted и `verify-install-ok`.
- [ ] Synology enrollment `plan` reviewed и `deploy-ok`.
- [ ] VPS backhaul artifact `deploy-ok`.
- [ ] VPS relay artifact `deploy-ok`.
- [ ] Public TCP/9921 принят внешним probe.
- [ ] Pilot B2 route согласован отдельно.
- [ ] Provider RSA-1024 recovery key удалён или принят как documented risk.
## 16. Исходники и проверки
Канонические файлы:
- `infra/deploy-runner/nodedc-b2-vps-deploy`;
- `infra/deploy-runner/build-device-edge-vps-artifact.mjs`;
- `infra/deploy-runner/test_device_edge_vps_artifact.py`;
- `device-plane/vps/config/`;
- `device-plane/vps/systemd/`;
- `device-plane/deployment/device-edge-vps-*-v1.json`;
- `infra/deploy-runner/nodedc-deploy`;
- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`;
- `infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py`.
Проверки на момент документа:
- VPS artifact tests: 9/9 OK;
- Device Plane targeted runner/artifact tests: 48/48 OK;
- полный platform `npm test`: 41/41 OK во внешнем сетевом sandbox;
- Python compilation: OK;
- systemd unit syntax: OK; отсутствие binaries до foundation было ожидаемым;
- `nft -c` foundation/relay: OK;
- effective candidate OpenSSH policy: key-only;
- `git diff --check`: OK для целевых файлов.
-38
View File
@@ -1,38 +0,0 @@
# 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 |
| Core/Edge trust direction | Core initiates the only private channel; VPS-initiated Synology connections are forbidden |
| Core channel authentication | TLS 1.3 mutual authentication; unknown/revoked Edge identity fails closed |
| Core channel bounds | Versioned envelopes, per-session and aggregate memory limits, keepalive and reconnect bounds |
| Pilot SLO | Zero pre-Core ACK/loss, p95/p99 acceptance latency, reconnect/dead-peer ceilings and no premature availability claim |
| Tracker acknowledgement | PACKAGE ACK is emitted only after bounded Core acceptance |
| Superseded transport freeze | Old VPS backhaul/relay/enrollment builders fail closed outside explicit test-only reconstruction |
| Superseded runner freeze | VPS runner rejects old phases and Synology runner rejects a prebuilt old enrollment artifact |
| Runtime preservation in Phase 0 | VPS, Synology, DNS, B2 routes, Gelios, Engine and Foundry remain unchanged |
-278
View File
@@ -1,278 +0,0 @@
{
"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-adapter-catalog": {
"resolved": "packages/device-adapter-catalog",
"link": true
},
"node_modules/@nodedc/device-adapter-runtime": {
"resolved": "packages/device-adapter-runtime",
"link": true
},
"node_modules/@nodedc/device-control-core": {
"resolved": "services/device-control-core",
"link": true
},
"node_modules/@nodedc/device-edge-channel": {
"resolved": "services/device-edge-channel",
"link": true
},
"node_modules/@nodedc/device-edge-channel-contract": {
"resolved": "packages/device-edge-channel-contract",
"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-gateway-core": {
"resolved": "services/device-gateway-core",
"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-adapter-catalog": {
"name": "@nodedc/device-adapter-catalog",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"packages/device-adapter-runtime": {
"name": "@nodedc/device-adapter-runtime",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"packages/device-edge-channel-contract": {
"name": "@nodedc/device-edge-channel-contract",
"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-channel": {
"name": "@nodedc/device-edge-channel",
"version": "0.1.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"
}
},
"services/device-gateway-core": {
"name": "@nodedc/device-gateway-core",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
}
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"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"
}
}
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,331 +0,0 @@
import { createHash } from "node:crypto";
import {
DEVICE_ADAPTER_CONTRACT_VERSION,
defineDeviceAdapter,
} from "../../device-adapter-runtime/src/index.mjs";
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: "typed-service-ping-v1",
exportedCommandBuilders: 1,
},
routeCompatibility: {
gelios: "parallel-preserved",
automaticCommandFailover: false,
},
});
export const ARUSNAVI_B2_ADAPTER = defineDeviceAdapter({
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
adapterRef: "arusnavi-b2",
runtimePackageRef: "@nodedc/arusnavi-b2-adapter",
profiles: [ARUSNAVI_B2_MODEL_PROFILE],
createSession({ profileRef } = {}) {
if (profileRef !== ARUSNAVI_B2_MODEL_PROFILE.profileRef) {
throw new TypeError("b2_adapter_profile_unsupported");
}
return {
parseHeader: tryParseB2Header2,
buildHeaderAcknowledgement: buildB2HeaderAcknowledgement,
parseMessage: tryParseB2Package,
buildMessageAcknowledgement(message) {
return buildB2PackageAcknowledgement(message.packageNumber);
},
buildTypedCommand: buildB2TypedCommand,
parseTypedCommandResponse: tryParseB2TypedCommandResponse,
};
},
});
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,
messageType: "telemetry.package",
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
payload: Object.freeze({
packageNumber,
packetCount,
byteLength: offset + 1,
packageDigest: `sha256:${createHash("sha256")
.update(input.subarray(0, offset + 1))
.digest("hex")}`,
}),
});
}
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 buildB2TypedCommand({ commandType, accessCode } = {}) {
if (commandType !== "service.ping") {
throw new TypeError("b2_typed_command_unsupported");
}
if (typeof accessCode !== "string" || !/^\d{6}$/.test(accessCode)) {
throw new TypeError("b2_command_access_code_invalid");
}
return Buffer.from(`${accessCode}*SERV*1.1`, "ascii");
}
export function tryParseB2TypedCommandResponse(input, { commandType } = {}) {
assertBuffer(input, "b2_command_response_buffer_required");
if (commandType !== "service.ping") {
throw new TypeError("b2_typed_command_unsupported");
}
const expected = Buffer.from("SERV OK", "ascii");
const compared = Math.min(input.length, expected.length);
if (!input.subarray(0, compared).equals(expected.subarray(0, compared))) {
return Object.freeze({ status: "not-command" });
}
if (input.length < expected.length) {
return Object.freeze({ status: "incomplete", minimumBytes: expected.length });
}
return Object.freeze({
status: "acknowledged",
bytesConsumed: expected.length,
resultCode: "serv_ok",
});
}
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 !== "typed-service-ping-v1") {
throw new TypeError("b2_command_transport_profile_invalid");
}
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);
}
@@ -1,164 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ARUSNAVI_B2_MODEL_PROFILE,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
assertB2ProfileInvariant,
buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
buildB2TypedCommand,
tryParseB2Header2,
tryParseB2Package,
tryParseB2TypedCommandResponse,
} 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",
);
const parsed = tryParseB2Package(specificationPackage);
assert.equal(parsed.status, "complete");
assert.equal(parsed.bytesConsumed, specificationPackage.length);
assert.equal(parsed.packageNumber, 1);
assert.equal(parsed.packetCount, 1);
assert.equal(parsed.messageType, "telemetry.package");
assert.equal(
parsed.payloadSchemaRef,
"arusnavi.internal.package-metadata.v1",
);
assert.deepEqual(parsed.payload, {
packageNumber: 1,
packetCount: 1,
byteLength: specificationPackage.length,
packageDigest:
"sha256:bba7205d2f613ac3dfdb7bccdee292b3837e1c8d3d1254ee68bba2dda3853e11",
});
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,
]);
const parsed = tryParseB2Package(packageBytes);
assert.equal(parsed.status, "complete");
assert.equal(parsed.bytesConsumed, packageBytes.length);
assert.equal(parsed.packageNumber, 2);
assert.equal(parsed.packetCount, 1);
assert.equal(parsed.messageType, "telemetry.package");
assert.equal(parsed.payload.byteLength, packageBytes.length);
assert.match(parsed.payload.packageDigest, /^sha256:[a-f0-9]{64}$/);
});
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 only the typed service-ping command", () => {
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status,
"typed-service-ping-v1",
);
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders,
1,
);
assert.equal(
buildB2TypedCommand({ commandType: "service.ping", accessCode: "123456" })
.toString("ascii"),
"123456*SERV*1.1",
);
assert.deepEqual(
tryParseB2TypedCommandResponse(Buffer.from("SERV OK", "ascii"), {
commandType: "service.ping",
}),
{ status: "acknowledged", bytesConsumed: 7, resultCode: "serv_ok" },
);
assert.throws(
() => buildB2TypedCommand({ commandType: "service.ping", accessCode: "12345" }),
/b2_command_access_code_invalid/,
);
assert.throws(
() => buildB2TypedCommand({ commandType: "firmware.update", accessCode: "123456" }),
/b2_typed_command_unsupported/,
);
});
@@ -1,15 +0,0 @@
{
"name": "@nodedc/device-adapter-catalog",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -1,13 +0,0 @@
import {
createDeviceAdapterRegistry,
} from "../../device-adapter-runtime/src/index.mjs";
import {
ARUSNAVI_B2_ADAPTER,
} from "../../arusnavi-b2-adapter/src/index.mjs";
export const DEVICE_ADAPTER_CATALOG = Object.freeze({
defaultProfileRef: "arusnavi.b2.internal.v1",
registry: createDeviceAdapterRegistry({
adapters: [ARUSNAVI_B2_ADAPTER],
}),
});
@@ -1,17 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEVICE_ADAPTER_CATALOG } from "../src/index.mjs";
test("allowlists ARUSNAVI B2 as the first adapter without making Gateway vendor-specific", () => {
assert.deepEqual(DEVICE_ADAPTER_CATALOG.registry.adapterRefs, ["arusnavi-b2"]);
assert.deepEqual(
DEVICE_ADAPTER_CATALOG.registry.profileRefs,
["arusnavi.b2.internal.v1"],
);
const registration = DEVICE_ADAPTER_CATALOG.registry.resolveProfile(
DEVICE_ADAPTER_CATALOG.defaultProfileRef,
);
assert.equal(registration.adapter.runtimePackageRef, "@nodedc/arusnavi-b2-adapter");
assert.equal(registration.profile.model, "B2");
});
@@ -1,15 +0,0 @@
{
"name": "@nodedc/device-adapter-runtime",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -1,151 +0,0 @@
export const DEVICE_ADAPTER_CONTRACT_VERSION =
"nodedc.device-adapter.v1";
const ADAPTER_REF_RE = /^[a-z][a-z0-9-]{1,62}$/;
const PROFILE_REF_RE = /^[a-z][a-z0-9._-]{2,127}$/;
const RUNTIME_PACKAGE_REF_RE = /^@[a-z0-9-]+\/[a-z0-9-]+$/;
export function defineDeviceAdapter(input) {
assertPlainObject(input, "device_adapter");
if (input.contractVersion !== DEVICE_ADAPTER_CONTRACT_VERSION) {
throw new TypeError("device_adapter_contract_version_invalid");
}
const adapterRef = normalizeRef(
input.adapterRef,
ADAPTER_REF_RE,
"device_adapter_ref_invalid",
);
const runtimePackageRef = normalizeRef(
input.runtimePackageRef,
RUNTIME_PACKAGE_REF_RE,
"device_adapter_runtime_package_ref_invalid",
);
if (!Array.isArray(input.profiles) || input.profiles.length === 0) {
throw new TypeError("device_adapter_profiles_required");
}
const profiles = input.profiles.map((profile) =>
normalizeProfile(profile, adapterRef)
);
if (new Set(profiles.map((profile) => profile.profileRef)).size !== profiles.length) {
throw new TypeError("device_adapter_profile_duplicate");
}
if (typeof input.createSession !== "function") {
throw new TypeError("device_adapter_session_factory_required");
}
return deepFreeze({
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
adapterRef,
runtimePackageRef,
profiles,
createSession: input.createSession,
});
}
export function createDeviceAdapterRegistry({ adapters = [] } = {}) {
if (!Array.isArray(adapters)) {
throw new TypeError("device_adapter_registry_adapters_invalid");
}
const byAdapterRef = new Map();
const byProfileRef = new Map();
for (const adapter of adapters) {
const normalized = defineDeviceAdapter(adapter);
if (byAdapterRef.has(normalized.adapterRef)) {
throw new TypeError("device_adapter_registry_adapter_duplicate");
}
byAdapterRef.set(normalized.adapterRef, normalized);
for (const profile of normalized.profiles) {
if (byProfileRef.has(profile.profileRef)) {
throw new TypeError("device_adapter_registry_profile_duplicate");
}
byProfileRef.set(profile.profileRef, Object.freeze({
adapter: normalized,
profile,
}));
}
}
return Object.freeze({
adapterRefs: Object.freeze([...byAdapterRef.keys()].sort()),
profileRefs: Object.freeze([...byProfileRef.keys()].sort()),
getAdapter(adapterRef) {
const normalized = normalizeRef(
adapterRef,
ADAPTER_REF_RE,
"device_adapter_ref_invalid",
);
const adapter = byAdapterRef.get(normalized);
if (!adapter) throw new TypeError("device_adapter_not_allowlisted");
return adapter;
},
resolveProfile(profileRef) {
const normalized = normalizeRef(
profileRef,
PROFILE_REF_RE,
"device_adapter_profile_ref_invalid",
);
const registration = byProfileRef.get(normalized);
if (!registration) {
throw new TypeError("device_adapter_profile_not_allowlisted");
}
return registration;
},
});
}
export function assertDeviceAdapterSession(session) {
assertPlainObject(session, "device_adapter_session");
for (const method of [
"parseHeader",
"buildHeaderAcknowledgement",
"parseMessage",
"buildMessageAcknowledgement",
]) {
if (typeof session[method] !== "function") {
throw new TypeError(`device_adapter_session_method_missing:${method}`);
}
}
return session;
}
function normalizeProfile(profile, adapterRef) {
assertPlainObject(profile, "device_adapter_profile");
const profileRef = normalizeRef(
profile.profileRef,
PROFILE_REF_RE,
"device_adapter_profile_ref_invalid",
);
if (profile.adapterRef != null && profile.adapterRef !== adapterRef) {
throw new TypeError("device_adapter_profile_adapter_mismatch");
}
const maxBufferedBytes = Number(profile?.framing?.maxBufferedBytes);
if (
!Number.isSafeInteger(maxBufferedBytes)
|| maxBufferedBytes < 1024
|| maxBufferedBytes > 256 * 1024
) {
throw new TypeError("device_adapter_profile_buffer_limit_invalid");
}
return deepFreeze({ ...profile, profileRef, adapterRef });
}
function normalizeRef(value, pattern, errorCode) {
if (typeof value !== "string" || !pattern.test(value)) {
throw new TypeError(errorCode);
}
return value;
}
function assertPlainObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label}_invalid`);
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_ADAPTER_CONTRACT_VERSION,
createDeviceAdapterRegistry,
defineDeviceAdapter,
} from "../src/index.mjs";
function adapter(adapterRef = "generic-tracker", profileRef = "generic.tracker.v1") {
return {
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
adapterRef,
runtimePackageRef: `@nodedc/${adapterRef}-adapter`,
profiles: [{
profileRef,
framing: { maxBufferedBytes: 64 * 1024 },
}],
createSession: () => ({}),
};
}
test("resolves only explicitly allowlisted adapter profiles", () => {
const registry = createDeviceAdapterRegistry({
adapters: [adapter()],
});
assert.deepEqual(registry.adapterRefs, ["generic-tracker"]);
assert.deepEqual(registry.profileRefs, ["generic.tracker.v1"]);
assert.equal(
registry.resolveProfile("generic.tracker.v1").adapter.adapterRef,
"generic-tracker",
);
assert.throws(
() => registry.resolveProfile("unknown.tracker.v1"),
/device_adapter_profile_not_allowlisted/,
);
});
test("rejects duplicate adapters and cross-adapter profile collisions", () => {
assert.throws(
() => createDeviceAdapterRegistry({ adapters: [adapter(), adapter()] }),
/device_adapter_registry_adapter_duplicate/,
);
assert.throws(
() => createDeviceAdapterRegistry({
adapters: [
adapter("generic-tracker", "shared.profile.v1"),
adapter("other-tracker", "shared.profile.v1"),
],
}),
/device_adapter_registry_profile_duplicate/,
);
});
test("freezes adapter metadata but keeps the session factory callable", () => {
const defined = defineDeviceAdapter(adapter());
assert.equal(Object.isFrozen(defined), true);
assert.equal(Object.isFrozen(defined.profiles[0]), true);
assert.equal(typeof defined.createSession, "function");
});
@@ -1,12 +0,0 @@
{
"name": "@nodedc/device-edge-channel-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -1,387 +0,0 @@
export const DEVICE_EDGE_CHANNEL_SCHEMA =
"nodedc.device-edge.channel-envelope.v1";
export const DEVICE_EDGE_CHANNEL_PATH =
"/internal/v1/device-edge/channel";
export const DEVICE_EDGE_CHANNEL_LIMITS = Object.freeze({
maxEnvelopeBytes: 1024 * 1024,
maxPendingAcceptances: 128,
keepaliveMs: 15_000,
deadPeerMs: 45_000,
reconnectMinimumMs: 1_000,
reconnectMaximumMs: 30_000,
});
export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([
"channel.hello",
"channel.heartbeat",
"tracker.session-opened",
"tracker.session-closed",
"discovery.observed",
"adapter.message",
"delivery.acknowledged",
"command.status",
"channel.counters",
]);
export const CORE_TO_EDGE_MESSAGE_KINDS = Object.freeze([
"channel.accepted",
"channel.heartbeat",
"flow.window",
"session.disposition",
"event.accepted",
"event.rejected",
]);
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
const forbiddenKeyFragments = Object.freeze([
"password",
"secret",
"credential",
"authorization",
"privatekey",
"bearertoken",
]);
export function normalizeChannelEnvelope(input, options = {}) {
assertPlainObject(input, "device_edge_channel_envelope");
const direction = normalizeDirection(options.direction);
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
const allowedKeys = new Set([
"schemaVersion",
"edgeRegistrationId",
"channelGeneration",
"trackerSessionId",
"adapterProfileRef",
"sequence",
"eventAt",
"receivedAt",
"payloadBytes",
"messageKind",
"correlationId",
"payload",
]);
rejectUnexpectedKeys(input, allowedKeys);
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_EDGE_CHANNEL_SCHEMA) {
throw new TypeError("device_edge_channel_schema_invalid");
}
const allowedKinds = direction === "edge-to-core"
? EDGE_TO_CORE_MESSAGE_KINDS
: CORE_TO_EDGE_MESSAGE_KINDS;
if (!allowedKinds.includes(input.messageKind)) {
throw new TypeError("device_edge_channel_message_kind_invalid");
}
const sequence = Number(input.sequence);
if (!Number.isSafeInteger(sequence) || sequence < 1) {
throw new TypeError("device_edge_channel_sequence_invalid");
}
assertJsonValue(input.payload, 0);
const payload = cloneJsonValue(input.payload);
const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
if (payloadBytes > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_payload_too_large");
}
if (Number(input.payloadBytes) !== payloadBytes) {
throw new TypeError("device_edge_channel_payload_length_mismatch");
}
const normalized = {
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
edgeRegistrationId: normalizeRef(
input.edgeRegistrationId,
"edge_registration_id",
),
channelGeneration: normalizeRef(
input.channelGeneration,
"channel_generation",
),
trackerSessionId: normalizeRef(
input.trackerSessionId,
"tracker_session_id",
),
adapterProfileRef: normalizeRef(
input.adapterProfileRef,
"adapter_profile_ref",
),
sequence,
eventAt: normalizeTimestamp(input.eventAt, "event_at"),
receivedAt: normalizeTimestamp(input.receivedAt, "received_at"),
payloadBytes,
messageKind: input.messageKind,
correlationId: normalizeRef(input.correlationId, "correlation_id"),
payload,
};
return deepFreeze(normalized);
}
export function createChannelEnvelope(fields, options = {}) {
assertPlainObject(fields, "device_edge_channel_fields");
const payload = fields.payload ?? {};
assertJsonValue(payload, 0);
const clonedPayload = cloneJsonValue(payload);
return normalizeChannelEnvelope({
...fields,
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
payloadBytes: Buffer.byteLength(JSON.stringify(clonedPayload), "utf8"),
payload: clonedPayload,
}, options);
}
export function encodeChannelEnvelope(envelope, options = {}) {
const normalized = normalizeChannelEnvelope(envelope, options);
const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8");
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
if (encoded.length > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
return encoded;
}
export function createChannelEnvelopeDecoder(options = {}) {
const direction = normalizeDirection(options.direction);
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
let buffered = Buffer.alloc(0);
return Object.freeze({
push(chunk) {
if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) {
throw new TypeError("device_edge_channel_chunk_invalid");
}
let incoming = Buffer.from(chunk);
const envelopes = [];
while (incoming.length > 0) {
const newlineIndex = incoming.indexOf(0x0a);
if (newlineIndex < 0) {
if (buffered.length + incoming.length > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
buffered = buffered.length === 0
? incoming
: Buffer.concat([buffered, incoming]);
break;
}
if (buffered.length + newlineIndex === 0) {
throw new TypeError("device_edge_channel_envelope_empty");
}
if (buffered.length + newlineIndex + 1 > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
const segment = incoming.subarray(0, newlineIndex);
const line = buffered.length === 0
? segment
: Buffer.concat([buffered, segment]);
buffered = Buffer.alloc(0);
incoming = incoming.subarray(newlineIndex + 1);
let parsed;
try {
parsed = JSON.parse(line.toString("utf8"));
} catch {
throw new TypeError("device_edge_channel_envelope_json_invalid");
}
envelopes.push(normalizeChannelEnvelope(parsed, {
direction,
maxEnvelopeBytes,
}));
}
return envelopes;
},
finish() {
if (buffered.length !== 0) {
throw new TypeError("device_edge_channel_envelope_truncated");
}
},
bufferedBytes() {
return buffered.length;
},
});
}
export function normalizeCertificateFingerprint(value) {
const compact = String(value || "").replaceAll(":", "").toUpperCase();
if (!/^[A-F0-9]{64}$/.test(compact)) {
throw new TypeError("device_edge_channel_certificate_fingerprint_invalid");
}
return compact.match(/.{2}/g).join(":");
}
export function normalizeCertificateIdentities(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 2) {
throw new TypeError("device_edge_channel_certificate_identities_invalid");
}
const generations = new Set();
const fingerprints = new Set();
let activeCount = 0;
const identities = value.map((identity) => {
assertPlainObject(identity, "device_edge_channel_certificate_identity");
rejectUnexpectedKeys(
identity,
new Set(["generationRef", "fingerprint", "status"]),
);
if (!["active", "staged"].includes(identity.status)) {
throw new TypeError("device_edge_channel_certificate_identity_status_invalid");
}
if (identity.status === "active") activeCount += 1;
const generationRef = normalizeRef(
identity.generationRef,
"certificate_generation_ref",
);
const fingerprint = normalizeCertificateFingerprint(identity.fingerprint);
if (generations.has(generationRef) || fingerprints.has(fingerprint)) {
throw new TypeError("device_edge_channel_certificate_identity_duplicate");
}
generations.add(generationRef);
fingerprints.add(fingerprint);
return Object.freeze({
generationRef,
fingerprint,
status: identity.status,
});
});
if (activeCount !== 1) {
throw new TypeError("device_edge_channel_active_certificate_identity_invalid");
}
return Object.freeze(identities);
}
export function nextReconnectDelay(attempt, options = {}) {
const normalizedAttempt = Number(attempt);
if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) {
throw new TypeError("device_edge_channel_reconnect_attempt_invalid");
}
const minimumMs = normalizeReconnectDuration(
options.minimumMs,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
);
const maximumMs = normalizeReconnectDuration(
options.maximumMs,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
);
if (maximumMs < minimumMs) {
throw new TypeError("device_edge_channel_reconnect_range_invalid");
}
const random = options.random ?? Math.random;
if (typeof random !== "function") {
throw new TypeError("device_edge_channel_random_invalid");
}
const ceiling = Math.min(maximumMs, minimumMs * (2 ** normalizedAttempt));
const floor = Math.max(minimumMs, Math.floor(ceiling / 2));
const sample = Number(random());
if (!Number.isFinite(sample) || sample < 0 || sample > 1) {
throw new TypeError("device_edge_channel_random_invalid");
}
return Math.floor(floor + ((ceiling - floor) * sample));
}
function normalizeReconnectDuration(value, fallback) {
const number = value == null ? fallback : Number(value);
if (!Number.isSafeInteger(number) || number < 10 || number > 120_000) {
throw new TypeError("device_edge_channel_reconnect_duration_invalid");
}
return number;
}
function normalizeDirection(value) {
if (!['edge-to-core', 'core-to-edge'].includes(value)) {
throw new TypeError("device_edge_channel_direction_invalid");
}
return value;
}
function normalizeLimit(value, fallback) {
const number = value == null ? fallback : Number(value);
if (!Number.isSafeInteger(number) || number < 256 || number > 4 * 1024 * 1024) {
throw new TypeError("device_edge_channel_limit_invalid");
}
return number;
}
function normalizeRef(value, field) {
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
return value;
}
function normalizeTimestamp(value, field) {
if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
return date.toISOString();
}
function assertPlainObject(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${name}_invalid`);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`${name}_invalid`);
}
}
function rejectUnexpectedKeys(value, allowedKeys) {
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
throw new TypeError(`device_edge_channel_field_unexpected:${key}`);
}
}
}
function rejectForbiddenKeys(value, depth = 0) {
if (depth > 16 || value == null || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
if (forbiddenKeyFragments.some((fragment) => compact.includes(fragment))) {
throw new TypeError(`device_edge_channel_forbidden_field:${key}`);
}
rejectForbiddenKeys(child, depth + 1);
}
}
function assertJsonValue(value, depth) {
if (depth > 16) {
throw new TypeError("device_edge_channel_payload_depth_invalid");
}
if (value == null || typeof value === "string" || typeof value === "boolean") {
return;
}
if (typeof value === "number" && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const child of value) assertJsonValue(child, depth + 1);
return;
}
assertPlainObject(value, "device_edge_channel_payload");
for (const child of Object.values(value)) {
assertJsonValue(child, depth + 1);
}
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}
@@ -1,106 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createChannelEnvelope,
createChannelEnvelopeDecoder,
encodeChannelEnvelope,
nextReconnectDelay,
normalizeCertificateIdentities,
normalizeChannelEnvelope,
} from "../src/index.mjs";
const now = "2026-08-11T12:00:00.000Z";
function envelope(overrides = {}) {
return createChannelEnvelope({
edgeRegistrationId: "edge:pilot-1",
channelGeneration: "generation:pilot-1",
trackerSessionId: "channel:control",
adapterProfileRef: "channel.control.v1",
sequence: 1,
eventAt: now,
receivedAt: now,
messageKind: "channel.hello",
correlationId: "correlation:hello-1",
payload: { status: "ready" },
...overrides,
}, { direction: "edge-to-core" });
}
test("round-trips a bounded versioned Edge envelope", () => {
const decoder = createChannelEnvelopeDecoder({ direction: "edge-to-core" });
const encoded = encodeChannelEnvelope(envelope(), {
direction: "edge-to-core",
});
const split = Math.floor(encoded.length / 2);
assert.deepEqual(decoder.push(encoded.subarray(0, split)), []);
assert.deepEqual(decoder.push(encoded.subarray(split)), [envelope()]);
assert.equal(decoder.bufferedBytes(), 0);
decoder.finish();
});
test("fails closed on unknown kinds, payload mismatches and oversized frames", () => {
const valid = envelope();
assert.throws(() => normalizeChannelEnvelope({
...valid,
messageKind: "tcp.forward",
}, { direction: "edge-to-core" }), /message_kind_invalid/);
assert.throws(() => normalizeChannelEnvelope({
...valid,
payloadBytes: valid.payloadBytes + 1,
}, { direction: "edge-to-core" }), /payload_length_mismatch/);
const decoder = createChannelEnvelopeDecoder({
direction: "edge-to-core",
maxEnvelopeBytes: 256,
});
assert.throws(() => decoder.push(Buffer.alloc(257, 0x61)), /envelope_too_large/);
});
test("uses bounded jittered exponential reconnect delays", () => {
assert.equal(nextReconnectDelay(0, {
minimumMs: 1000,
maximumMs: 30_000,
random: () => 1,
}), 1000);
assert.equal(nextReconnectDelay(5, {
minimumMs: 1000,
maximumMs: 30_000,
random: () => 1,
}), 30_000);
assert.equal(nextReconnectDelay(5, {
minimumMs: 1000,
maximumMs: 30_000,
random: () => 0,
}), 15_000);
});
test("allows exactly one active and at most one staged certificate generation", () => {
const activeFingerprint = "AA:".repeat(31) + "AA";
const stagedFingerprint = "BB:".repeat(31) + "BB";
const identities = normalizeCertificateIdentities([
{
generationRef: "trust-generation:1",
fingerprint: activeFingerprint,
status: "active",
},
{
generationRef: "trust-generation:2",
fingerprint: stagedFingerprint,
status: "staged",
},
]);
assert.equal(identities.length, 2);
assert.equal(identities[0].status, "active");
assert.equal(identities[1].status, "staged");
assert.throws(() => normalizeCertificateIdentities([
{ ...identities[0], status: "staged" },
identities[1],
]), /active_certificate_identity_invalid/);
assert.throws(() => normalizeCertificateIdentities([
identities[0],
{ ...identities[1], status: "active" },
]), /active_certificate_identity_invalid/);
});
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,530 +0,0 @@
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_ADAPTER_MESSAGE_SCHEMA =
"nodedc.device-adapter-message.v1";
export const DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA =
"nodedc.device-adapter-message-view.v1";
export const DEVICE_ADAPTER_ACCEPTANCE_SCHEMA =
"nodedc.device-adapter-acceptance.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 IDENTIFIER_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/-]{3,127}$/;
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
const IDENTIFIER_KIND_RE = /^[a-z][a-z0-9._:-]{1,63}$/;
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 routeRef = input.routeRef == null
? undefined
: normalizeEntityRef(input.routeRef, "route", "route_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,
...(routeRef ? { routeRef } : {}),
modelProfileRef,
protocol,
observedAt,
identifier,
evidence,
lifecycleState: "quarantine",
commandTransport: "disabled",
});
}
export function normalizeAdapterMessage(input, { maxBytes = 1024 * 1024 } = {}) {
assertPlainObject(input, "device_adapter_message");
const allowedKeys = new Set([
"schemaVersion",
"edgeRef",
"adapterRef",
"protocolProfileRef",
"protocol",
"sessionRef",
"routeRef",
"messageRef",
"messageType",
"sequence",
"observedAt",
"idempotencyKey",
"identifier",
"payloadSchemaRef",
"payload",
]);
rejectUnexpectedKeys(input, allowedKeys, "device_adapter_message_field_unexpected");
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_ADAPTER_MESSAGE_SCHEMA) {
throw new TypeError("device_adapter_message_schema_invalid");
}
const normalizedMaxBytes = normalizeByteLimit(maxBytes);
const serializedBytes = Buffer.byteLength(JSON.stringify(input), "utf8");
if (serializedBytes > normalizedMaxBytes) {
throw new TypeError("device_adapter_message_too_large");
}
const sequence = Number(input.sequence);
if (!Number.isSafeInteger(sequence) || sequence < 1) {
throw new TypeError("device_adapter_message_sequence_invalid");
}
assertPlainObject(input.payload, "device_adapter_message_payload");
assertJsonValue(input.payload, 0);
return deepFreeze({
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
edgeRef: normalizeOpaqueRef(input.edgeRef, "edge_ref"),
adapterRef: normalizeAdapterRef(input.adapterRef),
protocolProfileRef: normalizeOpaqueRef(
input.protocolProfileRef,
"protocol_profile_ref",
),
protocol: normalizeUpperToken(input.protocol, "protocol"),
sessionRef: normalizeOpaqueRef(input.sessionRef, "session_ref"),
...(input.routeRef == null
? {}
: { routeRef: normalizeEntityRef(input.routeRef, "route", "route_ref") }),
messageRef: normalizeOpaqueRef(input.messageRef, "message_ref"),
messageType: normalizeLowerToken(input.messageType, "message_type"),
sequence,
observedAt: normalizeTimestamp(input.observedAt, "observed_at"),
idempotencyKey: normalizeSha256Digest(
input.idempotencyKey,
"idempotency_key",
),
identifier: normalizeRestrictedIdentifier(input.identifier),
payloadSchemaRef: normalizeOpaqueRef(
input.payloadSchemaRef,
"payload_schema_ref",
),
payload: cloneJsonValue(input.payload),
});
}
export function toSafeAdapterMessageView(message) {
const normalized = normalizeAdapterMessage(message);
return deepFreeze({
schemaVersion: DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA,
edgeRef: normalized.edgeRef,
adapterRef: normalized.adapterRef,
protocolProfileRef: normalized.protocolProfileRef,
protocol: normalized.protocol,
sessionRef: normalized.sessionRef,
...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}),
messageRef: normalized.messageRef,
messageType: normalized.messageType,
sequence: normalized.sequence,
observedAt: normalized.observedAt,
idempotencyKey: normalized.idempotencyKey,
identifier: normalizeRestrictedIdentifierProjection({
kind: normalized.identifier.kind,
masked: maskRestrictedIdentifier(normalized.identifier),
}),
payloadSchemaRef: normalized.payloadSchemaRef,
payload: normalized.payload,
});
}
export function normalizeAdapterAcceptance(input) {
assertPlainObject(input, "device_adapter_acceptance");
const allowedKeys = new Set([
"schemaVersion",
"acceptanceRef",
"idempotencyKey",
"status",
"replayed",
"acceptedAt",
]);
rejectUnexpectedKeys(input, allowedKeys, "device_adapter_acceptance_field_unexpected");
if (input.schemaVersion !== DEVICE_ADAPTER_ACCEPTANCE_SCHEMA) {
throw new TypeError("device_adapter_acceptance_schema_invalid");
}
if (input.status !== "accepted") {
throw new TypeError("device_adapter_acceptance_status_invalid");
}
if (typeof input.replayed !== "boolean") {
throw new TypeError("device_adapter_acceptance_replayed_invalid");
}
return Object.freeze({
schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
acceptanceRef: normalizeOpaqueRef(input.acceptanceRef, "acceptance_ref"),
idempotencyKey: normalizeSha256Digest(
input.idempotencyKey,
"idempotency_key",
),
status: "accepted",
replayed: input.replayed,
acceptedAt: normalizeTimestamp(input.acceptedAt, "accepted_at"),
});
}
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 } : {}),
...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}),
modelProfileRef: normalized.modelProfileRef,
protocol: normalized.protocol,
observedAt: normalized.observedAt,
lifecycleState: normalized.lifecycleState,
identifier: normalizeRestrictedIdentifierProjection({
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 normalizeRestrictedIdentifierProjection(input) {
assertPlainObject(input, "restricted_identifier_projection");
const allowedKeys = new Set(["kind", "masked"]);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
throw new TypeError(
`restricted_identifier_projection_field_unexpected:${key}`,
);
}
}
if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) {
throw new TypeError("restricted_identifier_projection_kind_invalid");
}
if (
typeof input.masked !== "string"
|| input.masked.length < 5
|| input.masked.length > 128
|| !input.masked.includes("*")
|| /\u0000|[\u0001-\u001f\u007f]/.test(input.masked)
|| /\b\d{15}\b/.test(input.masked)
) {
throw new TypeError("restricted_identifier_projection_mask_invalid");
}
return Object.freeze({
kind: input.kind,
masked: input.masked,
});
}
export function normalizeRestrictedIdentifierRecord(input) {
assertPlainObject(input, "restricted_identifier_record");
const allowedKeys = new Set(["kind", "digest", "masked"]);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
throw new TypeError(
`restricted_identifier_record_field_unexpected:${key}`,
);
}
}
const projection = normalizeRestrictedIdentifierProjection({
kind: input.kind,
masked: input.masked,
});
return Object.freeze({
...projection,
digest: assertIdentifierDigest(input.digest),
});
}
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;
}
export function normalizeRestrictedIdentifier(input) {
assertPlainObject(input, "restricted_identifier");
rejectUnexpectedKeys(
input,
new Set(["kind", "value"]),
"restricted_identifier_field_unexpected",
);
if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) {
throw new TypeError("restricted_identifier_kind_invalid");
}
if (input.kind === "imei" && !IMEI_RE.test(input.value)) {
throw new TypeError("restricted_identifier_imei_invalid");
}
if (
typeof input.value !== "string"
|| !IDENTIFIER_VALUE_RE.test(input.value)
) {
throw new TypeError("restricted_identifier_value_invalid");
}
return Object.freeze({ kind: input.kind, value: input.value });
}
export function maskRestrictedIdentifier(identifier) {
const normalized = normalizeRestrictedIdentifier(identifier);
if (normalized.kind === "imei") {
return `***********${normalized.value.slice(-4)}`;
}
const visible = normalized.value.slice(-4);
const maskedLength = Math.min(
124,
Math.max(4, normalized.value.length - visible.length),
);
return `${"*".repeat(maskedLength)}${visible}`;
}
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 rejectUnexpectedKeys(input, allowedKeys, errorCode) {
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) throw new TypeError(`${errorCode}:${key}`);
}
}
function normalizeLowerToken(value, label) {
if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{1,127}$/.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeAdapterRef(value) {
if (typeof value !== "string" || !/^[a-z][a-z0-9-]{1,62}$/.test(value)) {
throw new TypeError("adapter_ref_invalid");
}
return value;
}
function normalizeSha256Digest(value, label) {
if (typeof value !== "string" || !SHA256_DIGEST_RE.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeByteLimit(value) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1024 || parsed > 1024 * 1024) {
throw new TypeError("device_adapter_message_limit_invalid");
}
return parsed;
}
function assertJsonValue(value, depth) {
if (depth > 12) throw new TypeError("device_adapter_message_payload_too_deep");
if (value === null || typeof value === "boolean" || typeof value === "string") {
if (typeof value === "string" && value.length > 64 * 1024) {
throw new TypeError("device_adapter_message_payload_string_too_large");
}
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("device_adapter_message_payload_number_invalid");
}
return;
}
if (Array.isArray(value)) {
if (value.length > 4096) {
throw new TypeError("device_adapter_message_payload_array_too_large");
}
value.forEach((item) => assertJsonValue(item, depth + 1));
return;
}
assertPlainObject(value, "device_adapter_message_payload");
if (Object.keys(value).length > 1024) {
throw new TypeError("device_adapter_message_payload_object_too_large");
}
for (const child of Object.values(value)) assertJsonValue(child, depth + 1);
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeOpaqueRef(value, label) {
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeEntityRef(value, prefix, label) {
if (
typeof value !== "string"
|| !new RegExp(
`^${prefix}:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
"i",
).test(value)
) {
throw new TypeError(`${label}_invalid`);
}
return value.toLowerCase();
}
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`);
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
@@ -1,265 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
DEVICE_ADAPTER_MESSAGE_SCHEMA,
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
DEVICE_PLANE_BINDING_SCHEMA,
assertIdentifierDigest,
assertSafeProjection,
hashRestrictedIdentifier,
maskRestrictedIdentifier,
normalizeDevicePlaneBinding,
normalizeAdapterAcceptance,
normalizeAdapterMessage,
normalizeDiscoverySignal,
normalizeRestrictedIdentifier,
normalizeRestrictedIdentifierProjection,
normalizeRestrictedIdentifierRecord,
toSafeDiscoveryView,
toSafeAdapterMessageView,
} 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("route-bound discovery preserves only a validated opaque route reference", () => {
const routeRef = "route:11111111-1111-4111-8111-111111111111";
const signal = normalizeDiscoverySignal({ ...fakeSignal, routeRef });
const view = toSafeDiscoveryView(signal);
assert.equal(signal.routeRef, routeRef);
assert.equal(view.routeRef, routeRef);
assert.throws(
() => normalizeDiscoverySignal({ ...fakeSignal, routeRef: "route:generic" }),
/route_ref_invalid/,
);
});
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("restricted identifier records keep digest internal and expose only a mask", () => {
const record = normalizeRestrictedIdentifierRecord({
kind: "vendor.serial",
digest: `hmac-sha256:${"a".repeat(64)}`,
masked: "********ABCD",
});
const projection = normalizeRestrictedIdentifierProjection({
kind: record.kind,
masked: record.masked,
});
assert.deepEqual(projection, {
kind: "vendor.serial",
masked: "********ABCD",
});
assert.equal("digest" in projection, false);
assertSafeProjection({ identifier: projection });
assert.throws(
() => normalizeRestrictedIdentifierProjection({
kind: "vendor.serial",
masked: "SERIAL-PLAINTEXT",
}),
/restricted_identifier_projection_mask_invalid/,
);
});
test("restricted identifiers support future adapter-defined hardware ids", () => {
const identifier = normalizeRestrictedIdentifier({
kind: "serial",
value: "SN-TRACKER-0001",
});
assert.deepEqual(identifier, {
kind: "serial",
value: "SN-TRACKER-0001",
});
assert.equal(maskRestrictedIdentifier(identifier), "***********0001");
assert.match(
hashRestrictedIdentifier(
identifier,
"test-only-pepper-with-at-least-32-bytes",
),
/^hmac-sha256:[a-f0-9]{64}$/,
);
});
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);
});
test("normalizes a bounded typed adapter message and masks its identity", () => {
const message = {
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
edgeRef: "edge:robot2b-vps-001",
adapterRef: "arusnavi-b2",
protocolProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
sessionRef: "session:test-001",
routeRef: "route:11111111-1111-4111-8111-111111111111",
messageRef: "package:1:abc123",
messageType: "telemetry.package",
sequence: 1,
observedAt: "2026-08-11T12:00:00.000Z",
idempotencyKey: `sha256:${"a".repeat(64)}`,
identifier: { kind: "imei", value: fakeImei },
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
payload: {
packageNumber: 1,
packetCount: 1,
packageDigest: `sha256:${"b".repeat(64)}`,
},
};
const normalized = normalizeAdapterMessage(message);
const safe = toSafeAdapterMessageView(normalized);
assert.equal(normalized.identifier.value, fakeImei);
assert.equal(safe.identifier.masked, "***********0001");
assert.equal(JSON.stringify(safe).includes(fakeImei), false);
assertSafeProjection(safe);
});
test("rejects oversized, untyped and secret-shaped adapter messages", () => {
const base = {
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
edgeRef: "edge:test",
adapterRef: "generic-tracker",
protocolProfileRef: "generic.tracker.v1",
protocol: "GENERIC",
sessionRef: "session:test",
messageRef: "message:1",
messageType: "telemetry.sample",
sequence: 1,
observedAt: "2026-08-11T12:00:00.000Z",
idempotencyKey: `sha256:${"a".repeat(64)}`,
identifier: { kind: "imei", value: fakeImei },
payloadSchemaRef: "generic.telemetry.v1",
payload: { value: 1 },
};
assert.throws(
() => normalizeAdapterMessage({ ...base, payload: "raw" }),
/device_adapter_message_payload_invalid/,
);
assert.throws(
() => normalizeAdapterMessage({
...base,
payload: { devicePassword: "forbidden" },
}),
/forbidden_device_field/,
);
assert.throws(
() => normalizeAdapterMessage({
...base,
payload: { value: "x".repeat(4096) },
}, { maxBytes: 1024 }),
/device_adapter_message_too_large/,
);
assert.throws(
() => normalizeAdapterMessage({
...base,
idempotencyKey: "message-not-a-digest",
}),
/idempotency_key_invalid/,
);
});
test("accepts only an explicit durable Core acceptance contract", () => {
const acceptance = normalizeAdapterAcceptance({
schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
acceptanceRef: "acceptance:test-001",
idempotencyKey: `sha256:${"a".repeat(64)}`,
status: "accepted",
replayed: false,
acceptedAt: "2026-08-11T12:00:00.000Z",
});
assert.equal(acceptance.status, "accepted");
assert.throws(
() => normalizeAdapterAcceptance({ ...acceptance, status: "queued" }),
/device_adapter_acceptance_status_invalid/,
);
});
@@ -1,123 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import test from "node:test";
const contractUrl = new URL(
"../../../deployment/device-edge-core-channel-v1.json",
import.meta.url,
);
const freezeUrl = new URL(
"../../../deployment/superseded-vps-initiated-transport-v1.json",
import.meta.url,
);
const sourceAcceptanceUrl = new URL(
"../../../deployment/device-edge-core-channel-source-v1.json",
import.meta.url,
);
const edgeBuilder = fileURLToPath(new URL(
"../../../../infra/deploy-runner/build-device-edge-vps-artifact.mjs",
import.meta.url,
));
const enrollmentBuilder = fileURLToPath(new URL(
"../../../../infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs",
import.meta.url,
));
async function readJson(url) {
return JSON.parse(await readFile(url, "utf8"));
}
test("pins a Core-initiated mutually authenticated Edge channel", async () => {
const contract = await readJson(contractUrl);
assert.equal(contract.status, "accepted-design");
assert.equal(contract.direction, "device-gateway-core-initiated");
assert.equal(contract.transport.tls, "TLSv1.3-mutual-authentication");
assert.equal(contract.transport.genericTcpForwarding, "forbidden");
assert.equal(contract.networkBoundary.synologyPublicIngress, false);
assert.equal(contract.networkBoundary.vpsInitiatedSynologyConnection, false);
assert.equal(contract.networkBoundary.subnetRoutes, false);
assert.equal(contract.networkBoundary.exitNode, false);
assert.equal(contract.identity.privateKeysInArtifacts, false);
});
test("requires Core acceptance before acknowledging tracker packages", async () => {
const contract = await readJson(contractUrl);
assert.equal(
contract.acknowledgement.trackerPackageAck,
"only-after-bounded-core-acceptance",
);
assert.equal(
contract.acknowledgement.coreUnavailable,
"do-not-acknowledge-tracker-package",
);
assert.equal(contract.acknowledgement.deliverySemantics, "at-least-once");
assert.equal(contract.pilotLimits.durableEdgeSpool, false);
assert.ok(contract.pilotLimits.maxBufferedBytesPerTrackerSession <= 262144);
assert.ok(contract.pilotLimits.maxAggregateBufferedBytes <= 33554432);
assert.equal(contract.pilotSlo.trackerAckBeforeDurableCoreAcceptance, 0);
assert.equal(contract.pilotSlo.lossOfCoreAcceptedPackages, 0);
assert.ok(
contract.pilotSlo.edgeReceiveToCoreAcceptanceP99Milliseconds <= 5000,
);
assert.ok(contract.pilotSlo.deadCoreDetectionHardCeilingSeconds <= 45);
});
test("records source acceptance without opening an Edge or tracker port", async () => {
const acceptance = await readJson(sourceAcceptanceUrl);
assert.equal(acceptance.status, "source-accepted");
assert.equal(acceptance.transport.initiator, "device-gateway-core");
assert.equal(acceptance.transport.tls, "TLSv1.3-mutual-authentication");
assert.equal(acceptance.identity.privateKeysInSource, false);
assert.equal(acceptance.identity.privateKeysInArtifact, false);
assert.equal(
acceptance.identity.rotation,
"one-active-plus-one-staged-generation",
);
assert.equal(acceptance.identity.retiredFingerprint, "reject");
assert.equal(acceptance.runtime.mutationInThisTransition, false);
assert.equal(acceptance.runtime.edgePort8443Published, false);
assert.equal(acceptance.runtime.trackerPort9921Published, false);
assert.equal(acceptance.runtime.synologyPublicIngress, false);
assert.equal(acceptance.runtime.commandTransport, "disabled");
assert.equal(acceptance.runtime.gelios, "untouched");
});
test("freezes the VPS-initiated Tailscale and SSH backhaul", async () => {
const freeze = await readJson(freezeUrl);
assert.equal(freeze.status, "frozen");
assert.equal(freeze.successor, "nodedc.device-edge.core-channel.v1");
assert.equal(freeze.runtimeMutationInPhase0, false);
assert.ok(freeze.forbiddenForNewPlanOrApply.includes(
"nodedc.device-plane.backhaul-vps-enrollment.v1",
));
assert.ok(freeze.forbiddenForNewPlanOrApply.includes(
"tailscale-userspace-key-only-ssh-local-forward",
));
});
test("superseded artifact builders fail closed outside test-only reconstruction", () => {
const environment = { ...process.env };
delete environment.NODEDC_ALLOW_SUPERSEDED_TRANSPORT;
const edge = spawnSync(
process.execPath,
[edgeBuilder, "backhaul", "superseded-backhaul-unit"],
{ encoding: "utf8", env: environment },
);
assert.notEqual(edge.status, 0);
assert.match(edge.stderr, /vps_initiated_transport_frozen:ADR-0001/);
const enrollment = spawnSync(
process.execPath,
[enrollmentBuilder, "superseded-enrollment-unit"],
{ encoding: "utf8", env: environment },
);
assert.notEqual(enrollment.status, 0);
assert.match(enrollment.stderr, /vps_initiated_transport_frozen:ADR-0001/);
});
@@ -1,12 +0,0 @@
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"]
@@ -1,35 +0,0 @@
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
@@ -1,17 +0,0 @@
FROM node:22-alpine
WORKDIR /app/services/device-control-core
COPY services/device-control-core/package.json services/device-control-core/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
WORKDIR /app
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract
COPY services/device-control-core ./services/device-control-core
USER node
CMD ["node", "services/device-control-core/src/server.mjs"]
@@ -1,99 +0,0 @@
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;
@@ -1,113 +0,0 @@
begin;
create table if not exists device_owner_scopes (
id uuid primary key,
scope_kind text not null
check (scope_kind in ('company', 'personal')),
owner_ref text not null
check (length(btrim(owner_ref)) between 3 and 256),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'suspended', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (scope_kind, owner_ref)
);
create table if not exists device_projects (
id uuid primary key,
owner_scope_id uuid not null references device_owner_scopes(id),
project_key text not null
check (project_key ~ '^[a-z][a-z0-9-]{1,62}$'),
name text not null
check (length(btrim(name)) between 1 and 160),
description text,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'suspended', 'archived')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (owner_scope_id, project_key)
);
create index if not exists device_projects_owner_scope_idx
on device_projects (owner_scope_id, lifecycle_state, updated_at desc);
alter table device_instances
add column if not exists project_id uuid references device_projects(id);
create unique index if not exists device_instances_id_project_idx
on device_instances (id, project_id);
create table if not exists device_collections (
id uuid primary key,
project_id uuid not null references device_projects(id),
collection_key text not null
check (collection_key ~ '^[a-z][a-z0-9-]{1,62}$'),
name text not null
check (length(btrim(name)) between 1 and 160),
description text,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'archived')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, collection_key),
unique (id, project_id)
);
create index if not exists device_collections_project_idx
on device_collections (project_id, lifecycle_state, updated_at desc);
create table if not exists device_collection_members (
collection_id uuid not null,
device_id uuid not null,
project_id uuid not null references device_projects(id),
added_by_ref text not null
check (length(btrim(added_by_ref)) between 3 and 256),
added_at timestamptz not null default now(),
primary key (collection_id, device_id),
foreign key (collection_id, project_id)
references device_collections(id, project_id),
foreign key (device_id, project_id)
references device_instances(id, project_id)
);
create index if not exists device_collection_members_device_idx
on device_collection_members (device_id, collection_id);
create table if not exists device_project_grants (
id uuid primary key,
project_id uuid not null references device_projects(id),
principal_kind text not null
check (principal_kind in ('user', 'group')),
principal_ref text not null
check (length(btrim(principal_ref)) between 3 and 256),
project_role text not null
check (project_role in ('viewer', 'operator', 'engineer', 'admin', 'owner')),
capability_allow text[] not null default '{}',
capability_deny text[] not null default '{}',
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'revoked')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, principal_kind, principal_ref),
check (not (capability_allow && capability_deny))
);
create index if not exists device_project_grants_principal_idx
on device_project_grants (
principal_kind,
principal_ref,
lifecycle_state,
project_id
);
commit;
@@ -1,68 +0,0 @@
begin;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'device_project_grants_owner_user_only'
and conrelid = 'device_project_grants'::regclass
) then
alter table device_project_grants
add constraint device_project_grants_owner_user_only
check (project_role <> 'owner' or principal_kind = 'user');
end if;
end
$$;
alter table device_audit_events
add column if not exists project_id uuid references device_projects(id);
create index if not exists device_audit_events_project_time_idx
on device_audit_events (project_id, occurred_at desc);
create table if not exists device_management_command_receipts (
id uuid primary key,
actor_ref text not null
check (length(btrim(actor_ref)) between 3 and 256),
command_kind text not null
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert'
)),
idempotency_key text not null
check (length(idempotency_key) between 8 and 256),
request_digest text not null
check (request_digest ~ '^sha256:[a-f0-9]{64}$'),
lifecycle_state text not null default 'pending'
check (lifecycle_state in ('pending', 'completed')),
response_status integer
check (response_status between 200 and 599),
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz,
unique (actor_ref, command_kind, idempotency_key),
check (
(
lifecycle_state = 'pending'
and response_status is null
and response_body is null
and completed_at is null
)
or
(
lifecycle_state = 'completed'
and response_status is not null
and response_body is not null
and completed_at is not null
)
)
);
create index if not exists device_management_receipts_created_idx
on device_management_command_receipts (created_at desc);
commit;
@@ -1,208 +0,0 @@
begin;
create table if not exists device_adapter_packages (
id uuid primary key,
package_key text not null
check (package_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
publisher_ref text not null
check (length(btrim(publisher_ref)) between 3 and 256),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (package_key)
);
create table if not exists device_adapter_versions (
id uuid primary key,
adapter_package_id uuid not null references device_adapter_packages(id),
version text not null
check (version ~ '^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)?$'),
runtime_package_ref text not null
check (length(btrim(runtime_package_ref)) between 3 and 256),
content_digest text not null
check (content_digest ~ '^sha256:[a-f0-9]{64}$'),
contract_version text not null
check (contract_version ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$'),
capabilities text[] not null default '{}'
check (
cardinality(capabilities) <= 64
and array_position(capabilities, null) is null
),
lifecycle_state text not null default 'draft'
check (lifecycle_state in ('draft', 'active', 'retired')),
registered_by_ref text not null
check (length(btrim(registered_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (adapter_package_id, version),
unique (runtime_package_ref, content_digest),
unique (id, adapter_package_id)
);
alter table device_model_profiles
add column if not exists adapter_version_id uuid
references device_adapter_versions(id),
add column if not exists schema_artifact_ref text
check (
schema_artifact_ref is null
or length(btrim(schema_artifact_ref)) between 3 and 256
),
add column if not exists profile_digest text
check (
profile_digest is null
or profile_digest ~ '^sha256:[a-f0-9]{64}$'
),
add column if not exists capabilities text[] not null default '{}'
check (
cardinality(capabilities) <= 64
and array_position(capabilities, null) is null
),
add column if not exists lifecycle_state text not null default 'active'
check (lifecycle_state in ('draft', 'active', 'retired'));
create index if not exists device_model_profiles_adapter_version_idx
on device_model_profiles (adapter_version_id, lifecycle_state, updated_at desc);
create table if not exists device_edges (
id uuid primary key,
edge_key text not null
check (edge_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
deployment_ref text
check (
deployment_ref is null
or length(btrim(deployment_ref)) between 3 and 256
),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (edge_key)
);
create table if not exists device_routes (
id uuid primary key,
project_id uuid not null references device_projects(id),
route_key text not null
check (route_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
edge_id uuid not null references device_edges(id),
model_profile_ref text not null references device_model_profiles(profile_ref),
listener_ref text not null
check (length(btrim(listener_ref)) between 3 and 256),
protocol text not null
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
direction text not null default 'telemetry'
check (direction in ('telemetry', 'bidirectional')),
lifecycle_state text not null default 'draft'
check (lifecycle_state in ('draft', 'active', 'suspended', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, route_key),
unique (id, project_id),
unique (id, edge_id, project_id),
unique (id, project_id, model_profile_ref)
);
create index if not exists device_routes_edge_state_idx
on device_routes (edge_id, lifecycle_state, updated_at desc);
create index if not exists device_routes_project_state_idx
on device_routes (project_id, lifecycle_state, updated_at desc);
create table if not exists device_sessions (
id uuid primary key,
session_ref text not null
check (length(btrim(session_ref)) between 3 and 256),
edge_id uuid not null,
project_id uuid not null,
route_id uuid not null,
device_id uuid,
protocol text not null
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
lifecycle_state text not null default 'connecting'
check (lifecycle_state in ('connecting', 'online', 'closing', 'closed', 'rejected')),
connected_at timestamptz not null,
last_seen_at timestamptz not null,
disconnected_at timestamptz,
close_reason_code text
check (
close_reason_code is null
or close_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
frame_count bigint not null default 0 check (frame_count >= 0),
byte_count bigint not null default 0 check (byte_count >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (edge_id, session_ref),
foreign key (route_id, edge_id, project_id)
references device_routes(id, edge_id, project_id),
foreign key (device_id, project_id)
references device_instances(id, project_id),
check (last_seen_at >= connected_at),
check (
(lifecycle_state in ('connecting', 'online') and disconnected_at is null)
or
(lifecycle_state in ('closing', 'closed', 'rejected'))
)
);
create index if not exists device_sessions_route_state_seen_idx
on device_sessions (route_id, lifecycle_state, last_seen_at desc);
create index if not exists device_sessions_device_seen_idx
on device_sessions (device_id, last_seen_at desc)
where device_id is not null;
create table if not exists device_enrollment_intents (
id uuid primary key,
project_id uuid not null references device_projects(id),
enrollment_key text not null
check (enrollment_key ~ '^[a-z][a-z0-9-]{1,62}$'),
route_id uuid not null,
model_profile_ref text not null references device_model_profiles(profile_ref),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
expected_identifier_kind text not null
check (expected_identifier_kind ~ '^[a-z][a-z0-9._-]{1,31}$'),
expected_identifier_digest text not null
check (expected_identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'),
expected_identifier_masked text not null
check (length(btrim(expected_identifier_masked)) between 4 and 64),
lifecycle_state text not null default 'pending'
check (lifecycle_state in ('pending', 'observed', 'claimed', 'cancelled', 'expired')),
expires_at timestamptz,
claimed_device_id uuid,
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, enrollment_key),
unique (
project_id,
expected_identifier_kind,
expected_identifier_digest,
model_profile_ref
),
foreign key (route_id, project_id, model_profile_ref)
references device_routes(id, project_id, model_profile_ref),
foreign key (claimed_device_id, project_id)
references device_instances(id, project_id),
check (expires_at is null or expires_at > created_at)
);
create index if not exists device_enrollment_intents_project_state_idx
on device_enrollment_intents (project_id, lifecycle_state, updated_at desc);
commit;
@@ -1,21 +0,0 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure'
));
commit;
@@ -1,326 +0,0 @@
begin;
create unique index if not exists device_projects_id_owner_scope_idx
on device_projects (id, owner_scope_id);
alter table device_instances
alter column contour_id drop not null,
add column if not exists owner_scope_id uuid references device_owner_scopes(id),
add column if not exists device_key text
check (
device_key is null
or device_key ~ '^[a-z][a-z0-9-]{1,62}$'
);
create unique index if not exists device_instances_project_key_idx
on device_instances (project_id, device_key)
where device_key is not null;
create unique index if not exists device_instances_id_project_owner_idx
on device_instances (id, project_id, owner_scope_id);
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'device_instances_project_owner_fk'
and conrelid = 'device_instances'::regclass
) then
alter table device_instances
add constraint device_instances_project_owner_fk
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_instances_ownership_mode_check'
and conrelid = 'device_instances'::regclass
) then
alter table device_instances
add constraint device_instances_ownership_mode_check
check (
(
owner_scope_id is not null
and project_id is not null
)
or
(
owner_scope_id is null
and project_id is null
and contour_id is not null
)
) not valid;
end if;
end
$$;
alter table device_discoveries
add column if not exists session_ref text
check (
session_ref is null
or length(btrim(session_ref)) between 3 and 256
),
add column if not exists project_id uuid references device_projects(id),
add column if not exists route_id uuid,
add column if not exists enrollment_intent_id uuid,
add column if not exists resolution_code text
check (
resolution_code is null
or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
add column if not exists resolved_at timestamptz,
add column if not exists resolved_by_ref text
check (
resolved_by_ref is null
or length(btrim(resolved_by_ref)) between 3 and 256
);
create unique index if not exists device_discoveries_id_project_idx
on device_discoveries (id, project_id);
create index if not exists device_discoveries_route_state_seen_idx
on device_discoveries (route_id, lifecycle_state, last_observed_at desc)
where route_id is not null;
create unique index if not exists device_enrollment_intents_context_idx
on device_enrollment_intents (
id,
project_id,
route_id,
model_profile_ref
);
create unique index if not exists device_enrollment_intents_active_identity_idx
on device_enrollment_intents (
expected_identifier_kind,
expected_identifier_digest,
model_profile_ref
)
where lifecycle_state in ('pending', 'observed', 'claimed');
alter table device_enrollment_intents
add column if not exists observed_discovery_id uuid,
add column if not exists observed_at timestamptz,
add column if not exists claimed_at timestamptz,
add column if not exists resolution_code text
check (
resolution_code is null
or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
add column if not exists resolved_at timestamptz,
add column if not exists resolved_by_ref text
check (
resolved_by_ref is null
or length(btrim(resolved_by_ref)) between 3 and 256
);
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'device_discoveries_route_context_fk'
and conrelid = 'device_discoveries'::regclass
) then
alter table device_discoveries
add constraint device_discoveries_route_context_fk
foreign key (route_id, project_id, model_profile_ref)
references device_routes(id, project_id, model_profile_ref)
not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_discoveries_enrollment_context_fk'
and conrelid = 'device_discoveries'::regclass
) then
alter table device_discoveries
add constraint device_discoveries_enrollment_context_fk
foreign key (
enrollment_intent_id,
project_id,
route_id,
model_profile_ref
) references device_enrollment_intents (
id,
project_id,
route_id,
model_profile_ref
) not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_discoveries_route_context_check'
and conrelid = 'device_discoveries'::regclass
) then
alter table device_discoveries
add constraint device_discoveries_route_context_check
check (
(project_id is null and route_id is null)
or
(project_id is not null and route_id is not null)
) not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_discoveries_enrollment_context_check'
and conrelid = 'device_discoveries'::regclass
) then
alter table device_discoveries
add constraint device_discoveries_enrollment_context_check
check (
enrollment_intent_id is null
or (project_id is not null and route_id is not null)
) not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_enrollment_observed_discovery_fk'
and conrelid = 'device_enrollment_intents'::regclass
) then
alter table device_enrollment_intents
add constraint device_enrollment_observed_discovery_fk
foreign key (observed_discovery_id, project_id)
references device_discoveries(id, project_id)
not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_enrollment_lifecycle_evidence_check'
and conrelid = 'device_enrollment_intents'::regclass
) then
alter table device_enrollment_intents
add constraint device_enrollment_lifecycle_evidence_check
check (
lifecycle_state not in ('observed', 'claimed')
or (observed_discovery_id is not null and observed_at is not null)
) not valid;
end if;
end
$$;
alter table device_sessions
drop constraint if exists device_sessions_device_id_project_id_fkey;
alter table device_enrollment_intents
drop constraint if exists device_enrollment_intents_claimed_device_id_project_id_fkey;
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'device_sessions_device_id_fk'
and conrelid = 'device_sessions'::regclass
) then
alter table device_sessions
add constraint device_sessions_device_id_fk
foreign key (device_id) references device_instances(id)
not valid;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_enrollment_claimed_device_id_fk'
and conrelid = 'device_enrollment_intents'::regclass
) then
alter table device_enrollment_intents
add constraint device_enrollment_claimed_device_id_fk
foreign key (claimed_device_id) references device_instances(id)
not valid;
end if;
end
$$;
create or replace function device_assert_session_current_project()
returns trigger
language plpgsql
as $$
begin
if new.device_id is not null and not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_session_project_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_sessions_current_project_guard
on device_sessions;
create trigger device_sessions_current_project_guard
before insert or update of device_id, project_id
on device_sessions
for each row
execute function device_assert_session_current_project();
create or replace function device_assert_enrollment_current_project()
returns trigger
language plpgsql
as $$
begin
if new.claimed_device_id is not null and not exists (
select 1 from device_instances di
where di.id = new.claimed_device_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_enrollment_project_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_enrollment_current_project_guard
on device_enrollment_intents;
create trigger device_enrollment_current_project_guard
before insert or update of claimed_device_id, project_id
on device_enrollment_intents
for each row
execute function device_assert_enrollment_current_project();
create table if not exists device_ownership_transitions (
id uuid primary key,
device_id uuid not null references device_instances(id),
transition_kind text not null
check (transition_kind in ('claim', 'transfer')),
source_owner_scope_id uuid,
source_project_id uuid,
target_owner_scope_id uuid not null,
target_project_id uuid not null,
actor_ref text not null
check (length(btrim(actor_ref)) between 3 and 256),
occurred_at timestamptz not null default now(),
foreign key (source_project_id, source_owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (target_project_id, target_owner_scope_id)
references device_projects(id, owner_scope_id),
check (
(
transition_kind = 'claim'
and source_owner_scope_id is null
and source_project_id is null
)
or
(
transition_kind = 'transfer'
and source_owner_scope_id is not null
and source_project_id is not null
and (
source_owner_scope_id <> target_owner_scope_id
or source_project_id <> target_project_id
)
)
)
);
create unique index if not exists device_ownership_single_claim_idx
on device_ownership_transitions (device_id)
where transition_kind = 'claim';
create index if not exists device_ownership_device_time_idx
on device_ownership_transitions (device_id, occurred_at desc);
commit;
@@ -1,25 +0,0 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure',
'device.claim',
'device.transfer',
'discovery.reject',
'discovery.expire'
));
commit;
@@ -1,233 +0,0 @@
begin;
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'device_instances_direct_legacy_credential_check'
and conrelid = 'device_instances'::regclass
) then
alter table device_instances
add constraint device_instances_direct_legacy_credential_check
check (owner_scope_id is null or credential_ref is null)
not valid;
end if;
end
$$;
create table if not exists device_restricted_identifiers (
id uuid primary key,
device_id uuid not null references device_instances(id),
owner_scope_id uuid not null,
project_id uuid not null,
identifier_kind text not null
check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
identifier_digest text not null
check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'),
identifier_masked text not null
check (
length(identifier_masked) between 5 and 128
and position('*' in identifier_masked) > 0
and identifier_masked !~ '[[:cntrl:]]'
and identifier_masked !~ '(^|[^0-9])[0-9]{15}([^0-9]|$)'
),
provenance_kind text not null
check (provenance_kind in ('claim', 'adapter_observation')),
is_primary boolean not null default false,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'revoked')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
revoked_at timestamptz,
revoked_by_ref text
check (
revoked_by_ref is null
or length(btrim(revoked_by_ref)) between 3 and 256
),
revocation_code text
check (
revocation_code is null
or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
check (
(lifecycle_state = 'active' and revoked_at is null and revoked_by_ref is null and revocation_code is null)
or
(lifecycle_state = 'revoked' and revoked_at is not null and revoked_by_ref is not null and revocation_code is not null)
)
);
create unique index if not exists device_restricted_identifiers_active_identity_idx
on device_restricted_identifiers (identifier_kind, identifier_digest)
where lifecycle_state = 'active';
create unique index if not exists device_restricted_identifiers_primary_idx
on device_restricted_identifiers (device_id)
where lifecycle_state = 'active' and is_primary;
create index if not exists device_restricted_identifiers_device_idx
on device_restricted_identifiers (device_id, lifecycle_state, created_at);
create or replace function device_assert_identifier_current_owner()
returns trigger
language plpgsql
as $$
begin
if new.lifecycle_state = 'active' and not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_identifier_ownership_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_restricted_identifiers_owner_guard
on device_restricted_identifiers;
create trigger device_restricted_identifiers_owner_guard
before insert or update of device_id, owner_scope_id, project_id, lifecycle_state
on device_restricted_identifiers
for each row
execute function device_assert_identifier_current_owner();
create or replace function device_assert_active_identifiers_follow_owner()
returns trigger
language plpgsql
as $$
begin
if exists (
select 1 from device_restricted_identifiers dri
where dri.device_id = new.id
and dri.lifecycle_state = 'active'
and (
dri.owner_scope_id is distinct from new.owner_scope_id
or dri.project_id is distinct from new.project_id
)
) then
raise foreign_key_violation using
message = 'device_active_identifier_ownership_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_instances_identifier_owner_guard
on device_instances;
create constraint trigger device_instances_identifier_owner_guard
after update
on device_instances
deferrable initially deferred
for each row
execute function device_assert_active_identifiers_follow_owner();
create table if not exists device_credential_bindings (
id uuid primary key,
device_id uuid not null references device_instances(id),
owner_scope_id uuid not null,
project_id uuid not null,
purpose text not null
check (purpose ~ '^[a-z][a-z0-9._-]{1,63}$'),
credential_owner text not null
check (credential_owner = 'ndc_l2_credentials'),
credential_ref text not null
check (credential_ref ~ '^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$'),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'revoked')),
bound_by_ref text not null
check (length(btrim(bound_by_ref)) between 3 and 256),
revoked_at timestamptz,
revoked_by_ref text
check (
revoked_by_ref is null
or length(btrim(revoked_by_ref)) between 3 and 256
),
revocation_code text
check (
revocation_code is null
or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
check (
(lifecycle_state = 'active' and revoked_at is null and revoked_by_ref is null and revocation_code is null)
or
(lifecycle_state = 'revoked' and revoked_at is not null and revoked_by_ref is not null and revocation_code is not null)
)
);
create unique index if not exists device_credential_bindings_active_purpose_idx
on device_credential_bindings (device_id, purpose)
where lifecycle_state = 'active';
create index if not exists device_credential_bindings_project_state_idx
on device_credential_bindings (project_id, lifecycle_state, updated_at desc);
create or replace function device_assert_credential_binding_current_owner()
returns trigger
language plpgsql
as $$
begin
if new.lifecycle_state = 'active' and not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_credential_binding_ownership_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_credential_bindings_owner_guard
on device_credential_bindings;
create trigger device_credential_bindings_owner_guard
before insert or update of device_id, owner_scope_id, project_id, lifecycle_state
on device_credential_bindings
for each row
execute function device_assert_credential_binding_current_owner();
create or replace function device_require_credential_revoke_before_transfer()
returns trigger
language plpgsql
as $$
begin
if exists (
select 1 from device_credential_bindings dcb
where dcb.device_id = old.id
and dcb.lifecycle_state = 'active'
) then
raise check_violation using
message = 'device_transfer_active_credential_binding';
end if;
return new;
end
$$;
drop trigger if exists device_instances_credential_transfer_guard
on device_instances;
create trigger device_instances_credential_transfer_guard
before update of owner_scope_id, project_id
on device_instances
for each row
when (
old.owner_scope_id is distinct from new.owner_scope_id
or old.project_id is distinct from new.project_id
)
execute function device_require_credential_revoke_before_transfer();
commit;
@@ -1,27 +0,0 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure',
'device.claim',
'device.transfer',
'discovery.reject',
'discovery.expire',
'device_credential_binding.upsert',
'device_credential_binding.revoke'
));
commit;
@@ -1,610 +0,0 @@
begin;
create table if not exists device_resource_bindings (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
binding_key text not null
check (binding_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
source_kind text not null
check (source_kind in ('device', 'collection')),
device_id uuid references device_instances(id),
collection_id uuid references device_collections(id),
target_kind text not null
check (target_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
target_ref text not null
check (length(btrim(target_ref)) between 3 and 256),
capabilities text[] not null default '{}'
check (
cardinality(capabilities) between 1 and 16
and array_position(capabilities, null) is null
),
lifecycle_state text not null default 'pending_external_approval'
check (lifecycle_state in ('pending_external_approval', 'active', 'revoked')),
source_approved_by_ref text not null
check (length(btrim(source_approved_by_ref)) between 3 and 256),
source_approved_at timestamptz not null default now(),
external_approval_ref text
check (
external_approval_ref is null
or length(btrim(external_approval_ref)) between 3 and 256
),
external_approval_digest text
check (
external_approval_digest is null
or external_approval_digest ~ '^sha256:[a-f0-9]{64}$'
),
external_approved_at timestamptz,
revoked_at timestamptz,
revoked_by_ref text
check (
revoked_by_ref is null
or length(btrim(revoked_by_ref)) between 3 and 256
),
revocation_code text
check (
revocation_code is null
or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, binding_key),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (collection_id, project_id)
references device_collections(id, project_id),
check (
(source_kind = 'device' and device_id is not null and collection_id is null)
or
(source_kind = 'collection' and device_id is null and collection_id is not null)
),
check (
(
lifecycle_state = 'pending_external_approval'
and external_approval_ref is null
and external_approval_digest is null
and external_approved_at is null
and revoked_at is null
and revoked_by_ref is null
and revocation_code is null
)
or
(
lifecycle_state = 'active'
and external_approval_ref is not null
and external_approval_digest is not null
and external_approved_at is not null
and revoked_at is null
and revoked_by_ref is null
and revocation_code is null
)
or
(
lifecycle_state = 'revoked'
and revoked_at is not null
and revoked_by_ref is not null
and revocation_code is not null
)
)
);
create index if not exists device_resource_bindings_project_state_idx
on device_resource_bindings (project_id, lifecycle_state, updated_at desc);
create index if not exists device_resource_bindings_device_state_idx
on device_resource_bindings (device_id, lifecycle_state, updated_at desc)
where device_id is not null;
create or replace function device_assert_binding_source_scope()
returns trigger
language plpgsql
as $$
begin
if new.source_kind = 'device' and not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using
message = 'device_binding_source_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_resource_bindings_source_guard
on device_resource_bindings;
create trigger device_resource_bindings_source_guard
before insert or update of owner_scope_id, project_id, source_kind, device_id, collection_id
on device_resource_bindings
for each row
execute function device_assert_binding_source_scope();
create table if not exists device_configuration_revisions (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
device_id uuid not null references device_instances(id),
revision_number bigint not null check (revision_number > 0),
model_profile_ref text not null references device_model_profiles(profile_ref),
schema_artifact_ref text not null
check (length(btrim(schema_artifact_ref)) between 3 and 256),
configuration_digest text not null
check (configuration_digest ~ '^sha256:[a-f0-9]{64}$'),
configuration jsonb not null
check (
jsonb_typeof(configuration) = 'object'
and octet_length(configuration::text) <= 65536
),
change_summary text
check (change_summary is null or length(change_summary) <= 1000),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
unique (device_id, revision_number),
unique (id, device_id, project_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_configuration_revisions_project_idx
on device_configuration_revisions (project_id, device_id, revision_number desc);
create or replace function device_assert_configuration_revision_scope()
returns trigger
language plpgsql
as $$
begin
if not exists (
select 1
from device_instances di
join device_model_profiles dmp
on dmp.profile_ref = di.model_profile_ref
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
and di.model_profile_ref = new.model_profile_ref
and dmp.schema_artifact_ref = new.schema_artifact_ref
and dmp.lifecycle_state = 'active'
) then
raise foreign_key_violation using
message = 'device_configuration_revision_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_configuration_revisions_scope_guard
on device_configuration_revisions;
create trigger device_configuration_revisions_scope_guard
before insert
on device_configuration_revisions
for each row
execute function device_assert_configuration_revision_scope();
create table if not exists device_configuration_state (
device_id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
desired_revision_id uuid,
applied_revision_id uuid,
applied_at timestamptz,
applied_by_ref text,
updated_at timestamptz not null default now(),
foreign key (device_id, project_id, owner_scope_id)
references device_instances(id, project_id, owner_scope_id),
foreign key (desired_revision_id, device_id, project_id)
references device_configuration_revisions(id, device_id, project_id),
foreign key (applied_revision_id, device_id, project_id)
references device_configuration_revisions(id, device_id, project_id),
check (desired_revision_id is not null or applied_revision_id is not null),
check (
(applied_revision_id is null and applied_at is null and applied_by_ref is null)
or
(applied_revision_id is not null and applied_at is not null and applied_by_ref is not null)
)
);
create table if not exists device_commands (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
device_id uuid not null references device_instances(id),
command_key text not null
check (command_key ~ '^[a-z][a-z0-9-]{1,62}$'),
command_catalog_ref text not null
check (length(btrim(command_catalog_ref)) between 3 and 256),
command_type text not null
check (command_type ~ '^[a-z][a-z0-9._:-]{1,63}$'),
risk_class text not null
check (risk_class in ('low', 'moderate', 'high', 'critical')),
parameters_digest text not null
check (parameters_digest ~ '^sha256:[a-f0-9]{64}$'),
parameters_projection jsonb not null
check (
jsonb_typeof(parameters_projection) = 'object'
and octet_length(parameters_projection::text) <= 16384
),
lifecycle_state text not null default 'draft'
check (lifecycle_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)),
planned_by_ref text not null
check (length(btrim(planned_by_ref)) between 3 and 256),
planned_at timestamptz not null default now(),
expires_at timestamptz not null,
confirmed_by_ref text,
confirmed_at timestamptz,
dispatched_at timestamptz,
transport_message_ref text
check (
transport_message_ref is null
or length(btrim(transport_message_ref)) between 3 and 256
),
acknowledged_at timestamptz,
terminal_at timestamptz,
terminal_reason_code text
check (
terminal_reason_code is null
or terminal_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, command_key),
unique (id, device_id, project_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
check (expires_at > planned_at),
check (
(confirmed_at is null and confirmed_by_ref is null)
or
(confirmed_at is not null and confirmed_by_ref is not null)
),
check (
(
lifecycle_state in ('dispatched', 'acknowledged', 'verified', 'unknown')
and dispatched_at is not null
and transport_message_ref is not null
)
or lifecycle_state not in ('dispatched', 'acknowledged', 'verified', 'unknown')
),
check (
(lifecycle_state in ('acknowledged', 'verified') and acknowledged_at is not null)
or lifecycle_state not in ('acknowledged', 'verified')
),
check (
(
lifecycle_state in ('verified', 'failed', 'expired', 'unknown')
and terminal_at is not null
and terminal_reason_code is not null
)
or
(
lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
and terminal_at is null
and terminal_reason_code is null
)
)
);
create index if not exists device_commands_project_state_idx
on device_commands (project_id, lifecycle_state, updated_at desc);
create index if not exists device_commands_device_state_idx
on device_commands (device_id, lifecycle_state, updated_at desc);
create table if not exists device_command_events (
id uuid primary key,
command_id uuid not null,
device_id uuid not null,
project_id uuid not null,
sequence_number bigint not null check (sequence_number > 0),
from_state text,
to_state text not null
check (to_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)),
actor_ref text not null
check (length(btrim(actor_ref)) between 3 and 256),
reason_code text not null
check (reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'),
evidence_ref text
check (
evidence_ref is null
or length(btrim(evidence_ref)) between 3 and 256
),
occurred_at timestamptz not null default now(),
unique (command_id, sequence_number),
foreign key (command_id, device_id, project_id)
references device_commands(id, device_id, project_id),
check (sequence_number = 1 or from_state is not null),
check (sequence_number <> 1 or from_state is null),
check (
from_state is null
or from_state in (
'draft',
'planned',
'awaiting_confirmation',
'queued',
'dispatched',
'acknowledged',
'verified',
'failed',
'expired',
'unknown'
)
)
);
create index if not exists device_command_events_command_idx
on device_command_events (command_id, sequence_number);
create or replace function device_assert_command_scope()
returns trigger
language plpgsql
as $$
begin
if not exists (
select 1 from device_instances di
where di.id = new.device_id
and di.owner_scope_id = new.owner_scope_id
and di.project_id = new.project_id
) then
raise foreign_key_violation using message = 'device_command_scope_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_commands_scope_guard on device_commands;
create trigger device_commands_scope_guard
before insert
on device_commands
for each row
execute function device_assert_command_scope();
create or replace function device_assert_command_event_sequence()
returns trigger
language plpgsql
as $$
declare
previous_state text;
begin
if new.sequence_number = 1 then
if new.from_state is not null or new.to_state <> 'draft' then
raise check_violation using message = 'device_command_initial_event_invalid';
end if;
return new;
end if;
select dce.to_state into previous_state
from device_command_events dce
where dce.command_id = new.command_id
and dce.sequence_number = new.sequence_number - 1;
if previous_state is null or previous_state <> new.from_state then
raise check_violation using message = 'device_command_event_sequence_invalid';
end if;
if not (
(new.from_state = 'draft' and new.to_state in ('planned', 'expired'))
or (new.from_state = 'planned' and new.to_state in ('awaiting_confirmation', 'queued', 'expired'))
or (new.from_state = 'awaiting_confirmation' and new.to_state in ('queued', 'expired'))
or (new.from_state = 'queued' and new.to_state in ('dispatched', 'failed', 'expired'))
or (new.from_state = 'dispatched' and new.to_state in ('acknowledged', 'failed', 'unknown'))
or (new.from_state = 'acknowledged' and new.to_state in ('verified', 'failed', 'unknown'))
) then
raise check_violation using message = 'device_command_event_transition_invalid';
end if;
return new;
end
$$;
drop trigger if exists device_command_events_sequence_guard
on device_command_events;
create trigger device_command_events_sequence_guard
before insert
on device_command_events
for each row
execute function device_assert_command_event_sequence();
create or replace function device_assert_command_transition()
returns trigger
language plpgsql
as $$
begin
if old.lifecycle_state = new.lifecycle_state then
return new;
end if;
if not (
(old.lifecycle_state = 'draft' and new.lifecycle_state in ('planned', 'expired'))
or (old.lifecycle_state = 'planned' and new.lifecycle_state in ('awaiting_confirmation', 'queued', 'expired'))
or (old.lifecycle_state = 'awaiting_confirmation' and new.lifecycle_state in ('queued', 'expired'))
or (old.lifecycle_state = 'queued' and new.lifecycle_state in ('dispatched', 'failed', 'expired'))
or (old.lifecycle_state = 'dispatched' and new.lifecycle_state in ('acknowledged', 'failed', 'unknown'))
or (old.lifecycle_state = 'acknowledged' and new.lifecycle_state in ('verified', 'failed', 'unknown'))
) then
raise check_violation using message = 'device_command_transition_invalid';
end if;
return new;
end
$$;
drop trigger if exists device_commands_transition_guard on device_commands;
create trigger device_commands_transition_guard
before update of lifecycle_state
on device_commands
for each row
execute function device_assert_command_transition();
create or replace function device_assert_command_event_projection()
returns trigger
language plpgsql
as $$
declare
current_state text;
latest_event_state text;
begin
select dc.lifecycle_state into current_state
from device_commands dc
where dc.id = new.id;
select dce.to_state into latest_event_state
from device_command_events dce
where dce.command_id = new.id
order by dce.sequence_number desc
limit 1;
if current_state is null or latest_event_state is distinct from current_state then
raise check_violation using message = 'device_command_event_projection_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_commands_event_projection_guard on device_commands;
create constraint trigger device_commands_event_projection_guard
after insert or update of lifecycle_state
on device_commands
deferrable initially deferred
for each row
execute function device_assert_command_event_projection();
create or replace function device_assert_command_current_projection()
returns trigger
language plpgsql
as $$
declare
current_state text;
latest_event_state text;
begin
select dc.lifecycle_state into current_state
from device_commands dc
where dc.id = new.command_id;
select dce.to_state into latest_event_state
from device_command_events dce
where dce.command_id = new.command_id
order by dce.sequence_number desc
limit 1;
if current_state is null or latest_event_state is distinct from current_state then
raise check_violation using message = 'device_command_current_projection_mismatch';
end if;
return new;
end
$$;
drop trigger if exists device_command_events_current_projection_guard
on device_command_events;
create constraint trigger device_command_events_current_projection_guard
after insert
on device_command_events
deferrable initially deferred
for each row
execute function device_assert_command_current_projection();
create or replace function device_reject_immutable_mutation()
returns trigger
language plpgsql
as $$
begin
raise check_violation using message = 'device_immutable_record_mutation_forbidden';
end
$$;
drop trigger if exists device_configuration_revisions_immutable_guard
on device_configuration_revisions;
create trigger device_configuration_revisions_immutable_guard
before update or delete or truncate
on device_configuration_revisions
for each statement
execute function device_reject_immutable_mutation();
drop trigger if exists device_command_events_immutable_guard
on device_command_events;
create trigger device_command_events_immutable_guard
before update or delete or truncate
on device_command_events
for each statement
execute function device_reject_immutable_mutation();
drop trigger if exists device_audit_events_immutable_guard
on device_audit_events;
create trigger device_audit_events_immutable_guard
before update or delete or truncate
on device_audit_events
for each statement
execute function device_reject_immutable_mutation();
create or replace function device_require_control_resources_clear_before_transfer()
returns trigger
language plpgsql
as $$
begin
if exists (
select 1 from device_resource_bindings drb
where drb.device_id = old.id
and drb.lifecycle_state in ('pending_external_approval', 'active')
) then
raise check_violation using message = 'device_transfer_active_resource_binding';
end if;
if exists (
select 1 from device_configuration_state dcs
where dcs.device_id = old.id
and dcs.applied_revision_id is not null
) then
raise check_violation using message = 'device_transfer_applied_configuration';
end if;
if exists (
select 1 from device_commands dc
where dc.device_id = old.id
and dc.lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
) then
raise check_violation using message = 'device_transfer_nonterminal_command';
end if;
return new;
end
$$;
drop trigger if exists device_instances_control_resource_transfer_guard
on device_instances;
create trigger device_instances_control_resource_transfer_guard
before update of owner_scope_id, project_id
on device_instances
for each row
when (
old.owner_scope_id is distinct from new.owner_scope_id
or old.project_id is distinct from new.project_id
)
execute function device_require_control_resources_clear_before_transfer();
commit;
@@ -1,31 +0,0 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure',
'device.claim',
'device.transfer',
'discovery.reject',
'discovery.expire',
'device_credential_binding.upsert',
'device_credential_binding.revoke',
'device_binding.ensure',
'device_binding.revoke',
'device_configuration_revision.create',
'device_configuration_desired.set'
));
commit;
@@ -1,66 +0,0 @@
begin;
create table if not exists device_gateway_message_receipts (
id uuid primary key,
idempotency_key text not null
check (idempotency_key ~ '^sha256:[a-f0-9]{64}$'),
request_digest text not null
check (request_digest ~ '^sha256:[a-f0-9]{64}$'),
edge_ref text not null
check (length(btrim(edge_ref)) between 3 and 128),
adapter_ref text not null
check (adapter_ref ~ '^[a-z][a-z0-9-]{1,62}$'),
protocol_profile_ref text not null
references device_model_profiles(profile_ref),
protocol text not null
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
route_id uuid references device_routes(id),
project_id uuid references device_projects(id),
session_ref text not null
check (length(btrim(session_ref)) between 3 and 128),
message_ref text not null
check (length(btrim(message_ref)) between 3 and 128),
message_type text not null
check (message_type ~ '^[a-z][a-z0-9._-]{1,127}$'),
sequence bigint not null check (sequence > 0),
identifier_kind text not null
check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
identifier_digest text not null
check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'),
identifier_masked text not null
check (length(identifier_masked) between 5 and 128),
payload_schema_ref text not null
check (length(btrim(payload_schema_ref)) between 3 and 128),
payload jsonb not null,
observed_at timestamptz not null,
accepted_at timestamptz not null default now(),
unique (idempotency_key),
unique (edge_ref, session_ref, message_ref),
foreign key (route_id, project_id)
references device_routes(id, project_id),
check (
(route_id is null and project_id is null)
or (route_id is not null and project_id is not null)
)
);
create index if not exists device_gateway_message_receipts_route_time_idx
on device_gateway_message_receipts (route_id, accepted_at desc)
where route_id is not null;
create index if not exists device_gateway_message_receipts_identity_time_idx
on device_gateway_message_receipts (
identifier_kind,
identifier_digest,
accepted_at desc
);
drop trigger if exists device_gateway_message_receipts_immutable_guard
on device_gateway_message_receipts;
create trigger device_gateway_message_receipts_immutable_guard
before update or delete or truncate
on device_gateway_message_receipts
for each statement
execute function device_reject_immutable_mutation();
commit;
@@ -1,54 +0,0 @@
begin;
alter table device_edges
add column if not exists channel_endpoint text,
add column if not exists channel_servername text,
add column if not exists channel_generation_ref text,
add column if not exists channel_trust_bundle_ref text,
add column if not exists channel_certificate_identities jsonb not null
default '[]'::jsonb,
add column if not exists channel_lifecycle_state text not null
default 'disabled';
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'device_edges_channel_lifecycle_state_check'
) then
alter table device_edges add constraint device_edges_channel_lifecycle_state_check
check (channel_lifecycle_state in ('disabled', 'active', 'revoked'));
end if;
if not exists (
select 1 from pg_constraint
where conname = 'device_edges_channel_configuration_check'
) then
alter table device_edges add constraint device_edges_channel_configuration_check
check (
(
channel_lifecycle_state = 'disabled'
and channel_endpoint is null
and channel_servername is null
and channel_generation_ref is null
and channel_trust_bundle_ref is null
and channel_certificate_identities = '[]'::jsonb
)
or
(
channel_lifecycle_state in ('active', 'revoked')
and length(btrim(channel_endpoint)) between 12 and 256
and channel_servername ~ '^[A-Za-z0-9.-]{1,253}$'
and channel_generation_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
and channel_trust_bundle_ref ~ '^edge-trust:[a-z][a-z0-9-]{1,62}$'
and jsonb_typeof(channel_certificate_identities) = 'array'
and jsonb_array_length(channel_certificate_identities) between 1 and 2
)
);
end if;
end $$;
create index if not exists device_edges_active_channel_idx
on device_edges (channel_lifecycle_state, updated_at desc)
where channel_lifecycle_state = 'active';
commit;
@@ -1,164 +0,0 @@
{
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"dependencies": {
"pg": "8.22.0"
},
"engines": {
"node": ">=20"
}
},
"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.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"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"
}
}
}
}
@@ -1,16 +0,0 @@
{
"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.22.0"
},
"engines": {
"node": ">=20"
}
}
@@ -1,504 +0,0 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import {
hashRestrictedIdentifier,
maskRestrictedIdentifier,
normalizeRestrictedIdentifier,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
import {
normalizeManagementActor,
} from "./project-management.mjs";
import { normalizeDeviceManagementCommand } from "./management-command.mjs";
const managementRoutes = new Map([
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
["/internal/v1/management/projects:ensure", "project.ensure"],
["/internal/v1/management/collections:ensure", "collection.ensure"],
["/internal/v1/management/project-grants:upsert", "project_grant.upsert"],
["/internal/v1/management/adapter-packages:ensure", "adapter_package.ensure"],
["/internal/v1/management/adapter-versions:register", "adapter_version.register"],
["/internal/v1/management/model-profiles:register", "model_profile.register"],
["/internal/v1/management/edges:ensure", "edge.ensure"],
["/internal/v1/management/routes:ensure", "route.ensure"],
["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"],
["/internal/v1/management/devices:claim", "device.claim"],
["/internal/v1/management/devices:transfer", "device.transfer"],
["/internal/v1/management/discoveries:reject", "discovery.reject"],
["/internal/v1/management/discoveries:expire", "discovery.expire"],
[
"/internal/v1/management/device-credential-bindings:upsert",
"device_credential_binding.upsert",
],
[
"/internal/v1/management/device-credential-bindings:revoke",
"device_credential_binding.revoke",
],
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
[
"/internal/v1/management/device-configuration-revisions:create",
"device_configuration_revision.create",
],
[
"/internal/v1/management/device-configurations:set-desired",
"device_configuration_desired.set",
],
]);
export function createControlCoreApp({
repository,
gatewayToken = "",
identifierPepper = "",
discoveryIngestEnabled = false,
managementApiEnabled = false,
managementToken = "",
gatewayIngest = null,
edgeChannelStatusProvider = null,
typedCommandRuntime = null,
} = {}) {
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 repository.acceptAdapterMessage !== "function") {
throw new TypeError("device_gateway_message_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");
}
}
if (managementApiEnabled) {
if (typeof repository.executeManagementCommand !== "function") {
throw new TypeError("device_management_repository_required");
}
if (typeof managementToken !== "string" || managementToken.length < 32) {
throw new TypeError("device_management_token_invalid");
}
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
throw new TypeError("device_identifier_pepper_invalid");
}
}
const ingest = discoveryIngestEnabled
? gatewayIngest ?? createDeviceGatewayIngest({ repository, identifierPepper })
: gatewayIngest;
if (
ingest
&& (
typeof ingest.observeDiscovery !== "function"
|| typeof ingest.acceptMessage !== "function"
)
) {
throw new TypeError("device_gateway_ingest_invalid");
}
if (
typedCommandRuntime != null
&& (
typeof typedCommandRuntime.planServicePing !== "function"
|| typeof typedCommandRuntime.status !== "function"
)
) {
throw new TypeError("device_typed_command_runtime_invalid");
}
if (
edgeChannelStatusProvider != null
&& typeof edgeChannelStatusProvider !== "function"
) {
throw new TypeError("device_edge_channel_status_provider_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",
managementApi: managementApiEnabled ? "enabled" : "disabled",
edgeChannels: edgeChannelStatusProvider
? edgeChannelStatusProvider()
: { enabled: false, configured: 0, accepted: 0, degraded: 0 },
commandTransport: typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
});
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/commands:service-ping"
) {
if (!managementApiEnabled || !typedCommandRuntime) {
return writeJson(response, 404, {
ok: false,
error: "device_command_transport_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
const idempotencyKey = normalizeIdempotencyKey(
request.headers["idempotency-key"],
);
const actor = managementActorFromHeaders(request.headers);
const input = await readJsonBody(request, 8 * 1024);
const execution = await typedCommandRuntime.planServicePing({
idempotencyKey,
actor,
input,
});
response.setHeader("Idempotency-Key", idempotencyKey);
response.setHeader(
"Idempotency-Replayed",
execution.replayed ? "true" : "false",
);
return writeJson(response, 200, {
ok: true,
replayed: execution.replayed,
result: execution.command,
});
}
const managementCommandKind = managementRoutes.get(requestUrl.pathname);
if (request.method === "POST" && managementCommandKind) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
const idempotencyKey = normalizeIdempotencyKey(
request.headers["idempotency-key"],
);
const actor = managementActorFromHeaders(request.headers);
const input = await readJsonBody(request, 64 * 1024);
const protectedInput = managementCommandKind === "enrollment_intent.ensure"
? protectEnrollmentIdentifier(input, identifierPepper)
: input;
const command = normalizeDeviceManagementCommand(
managementCommandKind,
protectedInput,
);
const requestDigest = managementRequestDigest({
actor,
commandKind: managementCommandKind,
command,
});
const execution = await repository.executeManagementCommand({
idempotencyKey,
commandKind: managementCommandKind,
requestDigest,
actor,
command,
});
response.setHeader("Idempotency-Key", idempotencyKey);
response.setHeader(
"Idempotency-Replayed",
execution.replayed ? "true" : "false",
);
return writeJson(response, 200, {
ok: true,
replayed: execution.replayed,
result: execution.result,
});
}
if (
request.method === "GET"
&& requestUrl.pathname === "/internal/v1/query/projects"
) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
if (typeof repository.listAccessibleProjects !== "function") {
return writeJson(response, 503, {
ok: false,
error: "device_query_repository_unavailable",
});
}
const actor = managementActorFromHeaders(request.headers);
const projects = await repository.listAccessibleProjects(actor);
return writeJson(response, 200, { ok: true, projects });
}
const workspaceProjectId = projectWorkspaceId(requestUrl.pathname);
if (request.method === "GET" && workspaceProjectId) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
if (typeof repository.getProjectWorkspace !== "function") {
return writeJson(response, 503, {
ok: false,
error: "device_query_repository_unavailable",
});
}
const actor = managementActorFromHeaders(request.headers);
const workspace = await repository.getProjectWorkspace(
actor,
workspaceProjectId,
{
commandTransport: typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
},
);
return writeJson(response, 200, { ok: true, workspace });
}
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 discovery = await ingest.observeDiscovery(input);
return writeJson(response, discovery.created ? 201 : 200, {
ok: true,
created: discovery.created,
discovery: discovery.value,
});
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/gateway/messages:accept"
) {
if (!discoveryIngestEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_gateway_message_ingest_disabled",
});
}
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_gateway_auth_required",
});
}
const input = await readJsonBody(request, 1024 * 1024);
const receipt = await ingest.acceptMessage(input);
const acceptance = receipt.value;
return writeJson(response, acceptance.replayed ? 200 : 201, {
ok: true,
acceptance,
});
}
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 protectEnrollmentIdentifier(input, identifierPepper) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("device_enrollment_input_invalid");
}
const allowedKeys = new Set([
"projectRef",
"enrollmentKey",
"routeRef",
"modelProfileRef",
"displayName",
"identifier",
"expiresAt",
]);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
throw new TypeError("device_enrollment_input_field_unexpected");
}
}
const identifier = normalizeRestrictedIdentifier(input.identifier);
return Object.freeze({
projectRef: input.projectRef,
enrollmentKey: input.enrollmentKey,
routeRef: input.routeRef,
modelProfileRef: input.modelProfileRef,
displayName: input.displayName,
identifierKind: identifier.kind,
identifierDigest: hashRestrictedIdentifier(identifier, identifierPepper),
identifierMasked: maskRestrictedIdentifier(identifier),
expiresAt: input.expiresAt,
});
}
function projectWorkspaceId(pathname) {
const match = pathname.match(
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/workspace$/i,
);
return match?.[1]?.toLowerCase() ?? null;
}
function managementActorFromHeaders(headers) {
return normalizeManagementActor({
userRef: singleHeader(headers["x-nodedc-user-ref"]),
hubRole: singleHeader(headers["x-nodedc-hub-role"]),
groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]),
ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]),
});
}
function ownerScopeHeader(value) {
return commaSeparatedHeader(value).map((claim) => {
const separatorIndex = claim.indexOf("=");
if (separatorIndex < 1 || separatorIndex === claim.length - 1) {
throw new TypeError("device_actor_owner_scopes_invalid");
}
return {
scopeKind: claim.slice(0, separatorIndex),
ownerRef: claim.slice(separatorIndex + 1),
};
});
}
function commaSeparatedHeader(value) {
const header = singleHeader(value, true);
if (!header) return [];
return header.split(",").map((item) => item.trim()).filter(Boolean);
}
function singleHeader(value, optional = false) {
if (Array.isArray(value)) throw new TypeError("device_management_header_invalid");
if (value == null || value === "") {
if (optional) return "";
throw new TypeError("device_management_header_required");
}
if (typeof value !== "string" || value.length > 4096) {
throw new TypeError("device_management_header_invalid");
}
return value.trim();
}
function normalizeIdempotencyKey(value) {
const key = singleHeader(value);
if (!/^[\x21-\x7e]{8,256}$/.test(key)) {
const error = new Error("device_idempotency_key_invalid");
error.statusCode = 400;
throw error;
}
return key;
}
function managementRequestDigest(value) {
return `sha256:${createHash("sha256")
.update(JSON.stringify(value), "utf8")
.digest("hex")}`;
}
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";
}
@@ -1,284 +0,0 @@
import { createHash } from "node:crypto";
import {
DEVICE_BINDING_CAPABILITIES,
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
"device_binding.ensure",
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
]);
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
export function isControlResourceManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeControlResourceManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
assertPlainObject(input, "device_control_resource_command_invalid");
if (kind === "device_binding.ensure") {
assertAllowedKeys(input, [
"projectRef",
"bindingKey",
"displayName",
"source",
"targetKind",
"targetRef",
"capabilities",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingKey: normalizePattern(
input.bindingKey,
keyPattern,
"device_binding_key_invalid",
),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_binding_name_invalid",
),
source: normalizeBindingSource(input.source),
targetKind: normalizePattern(
input.targetKind,
tokenPattern,
"device_binding_target_kind_invalid",
),
targetRef: normalizeTargetRef(input.targetRef),
capabilities: Object.freeze(normalizeBindingCapabilities(
input.capabilities,
)),
});
}
if (kind === "device_binding.revoke") {
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
resolutionCode: normalizePattern(
input.resolutionCode,
resolutionPattern,
"device_binding_resolution_code_invalid",
),
});
}
if (kind === "device_configuration_revision.create") {
assertAllowedKeys(input, [
"projectRef",
"deviceRef",
"configuration",
"changeSummary",
]);
const configuration = normalizeDeviceConfiguration(input.configuration);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
configuration,
configurationDigest: `sha256:${createHash("sha256")
.update(JSON.stringify(configuration), "utf8")
.digest("hex")}`,
changeSummary: normalizeOptionalText(
input.changeSummary,
1000,
"device_configuration_change_summary_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef",
"deviceRef",
"configurationRevisionRef",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
configurationRevisionId: normalizeEntityRef(
input.configurationRevisionRef,
"configuration-revision",
),
});
}
export function normalizeDeviceConfiguration(input) {
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
throw new TypeError("device_configuration_must_be_object");
}
if (Object.keys(normalized).length === 0) {
throw new TypeError("device_configuration_must_not_be_empty");
}
const serialized = JSON.stringify(normalized);
if (Buffer.byteLength(serialized, "utf8") > 32768) {
throw new TypeError("device_configuration_too_large");
}
assertSafeProjection({ configuration: normalized });
return deepFreeze(normalized);
}
function normalizeBindingSource(input) {
assertPlainObject(input, "device_binding_source_invalid");
assertAllowedKeys(input, ["kind", "ref"]);
if (input.kind === "device") {
return Object.freeze({
kind: "device",
id: normalizeEntityRef(input.ref, "device"),
});
}
if (input.kind === "collection") {
return Object.freeze({
kind: "collection",
id: normalizeEntityRef(input.ref, "collection"),
});
}
throw new TypeError("device_binding_source_kind_invalid");
}
function normalizeBindingCapabilities(input) {
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
throw new TypeError("device_binding_capabilities_invalid");
}
const normalized = input.map((value) => {
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
throw new TypeError("device_binding_capability_invalid");
}
return value;
});
if (new Set(normalized).size !== normalized.length) {
throw new TypeError("device_binding_capabilities_duplicate");
}
return normalized.sort();
}
function normalizeTargetRef(value) {
if (
typeof value !== "string"
|| !targetRefPattern.test(value)
|| secretReferencePattern.test(value)
) {
throw new TypeError("device_binding_target_ref_invalid");
}
assertSafeProjection({ targetRef: value });
return value;
}
function normalizeConfigurationValue(value, depth, path) {
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
if (value === null || typeof value === "boolean") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError(`device_configuration_number_invalid:${path}`);
}
return value;
}
if (typeof value === "string") {
if (
value.length > 1000
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
) {
throw new TypeError(`device_configuration_string_invalid:${path}`);
}
return value;
}
if (Array.isArray(value)) {
if (value.length > 64) {
throw new TypeError(`device_configuration_array_invalid:${path}`);
}
return value.map((item, index) =>
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
);
}
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
const keys = Object.keys(value);
if (keys.length > 64) {
throw new TypeError(`device_configuration_object_invalid:${path}`);
}
const normalized = {};
for (const key of keys.sort()) {
if (!configurationKeyPattern.test(key)) {
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
}
normalized[key] = normalizeConfigurationValue(
value[key],
depth + 1,
`${path}.${key}`,
);
}
return normalized;
}
function normalizeEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizePattern(value, pattern, code) {
if (typeof value !== "string" || !pattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeDisplayText(value, maxLength, code) {
if (typeof value !== "string") throw new TypeError(code);
const normalized = value.trim();
if (
normalized.length < 1
|| normalized.length > maxLength
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
) {
throw new TypeError(code);
}
return normalized;
}
function normalizeOptionalText(value, maxLength, code) {
if (value == null || value === "") return null;
return normalizeDisplayText(value, maxLength, code);
}
function assertPlainObject(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(code);
}
}
function assertAllowedKeys(input, allowed) {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (!allowedSet.has(key)) {
throw new TypeError(`device_management_command_field_unexpected:${key}`);
}
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}
@@ -1,498 +0,0 @@
import { randomUUID } from "node:crypto";
import {
isControlResourceManagementCommand,
} from "./control-resource-management.mjs";
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
import { toProjectRef } from "./project-management.mjs";
export async function applyControlResourceManagementCommand(
client,
{ commandKind, actor, command },
) {
if (!isControlResourceManagementCommand(commandKind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
if (commandKind === "device_binding.ensure") {
return ensureBinding(client, actor, command);
}
if (commandKind === "device_binding.revoke") {
return revokeBinding(client, actor, command);
}
if (commandKind === "device_configuration_revision.create") {
return createConfigurationRevision(client, actor, command);
}
return setDesiredConfiguration(client, actor, command);
}
export async function authorizeControlResourceManagementReplay(
client,
{ commandKind, actor, command },
) {
if (!isControlResourceManagementCommand(commandKind)) {
throw new TypeError("device_control_resource_command_kind_invalid");
}
const capability = commandKind.startsWith("device_binding.")
? "binding.manage"
: "configuration.manage";
await findProjectWithCapability(
client,
actor,
command.projectId,
capability,
);
if (command.deviceId) {
const current = await client.query(
`select project_id from device_instances where id = $1`,
[command.deviceId],
);
const currentProjectId = current.rows[0]?.project_id;
if (!currentProjectId) throw domainError("device_not_found", 404);
if (currentProjectId !== command.projectId) {
await findProjectWithCapability(
client,
actor,
currentProjectId,
capability,
);
}
}
}
async function ensureBinding(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"binding.manage",
);
const source = await findBindingSource(client, command);
const bindingId = randomUUID();
const result = await client.query(
`insert into device_resource_bindings (
id,
owner_scope_id,
project_id,
binding_key,
display_name,
source_kind,
device_id,
collection_id,
target_kind,
target_ref,
capabilities,
source_approved_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (project_id, binding_key) do update set
display_name = excluded.display_name,
capabilities = excluded.capabilities,
updated_at = now()
where device_resource_bindings.lifecycle_state = 'pending_external_approval'
and device_resource_bindings.source_kind = excluded.source_kind
and device_resource_bindings.device_id is not distinct from excluded.device_id
and device_resource_bindings.collection_id is not distinct from excluded.collection_id
and device_resource_bindings.target_kind = excluded.target_kind
and device_resource_bindings.target_ref = excluded.target_ref
returning id, owner_scope_id, project_id, binding_key, display_name,
source_kind, device_id, collection_id, target_kind, target_ref,
capabilities, lifecycle_state, source_approved_at, created_at, updated_at,
(xmax = 0) as created`,
[
bindingId,
project.owner_scope_id,
project.id,
command.bindingKey,
command.displayName,
command.source.kind,
command.source.kind === "device" ? source.id : null,
command.source.kind === "collection" ? source.id : null,
command.targetKind,
command.targetRef,
command.capabilities,
actor.userRef,
],
);
const binding = result.rows[0];
if (!binding) throw domainError("device_binding_identity_conflict", 409);
await addAudit(client, {
eventType: binding.created
? "device_binding.created"
: "device_binding.updated",
actorRef: actor.userRef,
projectId: project.id,
deviceId: binding.device_id,
payload: {
bindingRef: `binding:${binding.id}`,
projectRef: toProjectRef(project.id),
bindingKey: binding.binding_key,
sourceKind: binding.source_kind,
sourceRef: bindingSourceRef(binding),
targetKind: binding.target_kind,
targetRef: binding.target_ref,
lifecycleState: binding.lifecycle_state,
},
});
return {
created: binding.created === true,
binding: bindingView(binding),
};
}
async function revokeBinding(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"binding.manage",
);
const result = await client.query(
`update device_resource_bindings
set lifecycle_state = 'revoked',
revoked_at = now(),
revoked_by_ref = $3,
revocation_code = $4,
updated_at = now()
where id = $1
and project_id = $2
and lifecycle_state <> 'revoked'
returning id, owner_scope_id, project_id, binding_key, display_name,
source_kind, device_id, collection_id, target_kind, target_ref,
capabilities, lifecycle_state, source_approved_at, created_at, updated_at`,
[command.bindingId, project.id, actor.userRef, command.resolutionCode],
);
const binding = result.rows[0];
if (!binding) throw domainError("device_binding_not_found", 404);
await addAudit(client, {
eventType: "device_binding.revoked",
actorRef: actor.userRef,
projectId: project.id,
deviceId: binding.device_id,
payload: {
bindingRef: `binding:${binding.id}`,
projectRef: toProjectRef(project.id),
sourceKind: binding.source_kind,
sourceRef: bindingSourceRef(binding),
targetKind: binding.target_kind,
targetRef: binding.target_ref,
lifecycleState: binding.lifecycle_state,
resolutionCode: command.resolutionCode,
},
});
return {
revoked: true,
binding: bindingView(binding),
resolutionCode: command.resolutionCode,
};
}
async function createConfigurationRevision(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"configuration.manage",
);
const device = await findDirectDeviceForUpdate(client, command);
const profileResult = await client.query(
`select profile_ref, schema_artifact_ref, lifecycle_state
from device_model_profiles
where profile_ref = $1
for share`,
[device.model_profile_ref],
);
const profile = profileResult.rows[0];
if (
!profile
|| profile.lifecycle_state !== "active"
|| !profile.schema_artifact_ref
) {
throw domainError("device_configuration_profile_unavailable", 409);
}
const nextResult = await client.query(
`select coalesce(max(revision_number), 0) + 1 as next_revision
from device_configuration_revisions
where device_id = $1`,
[device.id],
);
const revisionNumber = Number(nextResult.rows[0]?.next_revision);
if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) {
throw domainError("device_configuration_revision_sequence_invalid", 409);
}
const revisionId = randomUUID();
const inserted = await client.query(
`insert into device_configuration_revisions (
id,
owner_scope_id,
project_id,
device_id,
revision_number,
model_profile_ref,
schema_artifact_ref,
configuration_digest,
configuration,
change_summary,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
returning id, owner_scope_id, project_id, device_id, revision_number,
model_profile_ref, schema_artifact_ref, configuration_digest,
configuration, change_summary, created_at`,
[
revisionId,
project.owner_scope_id,
project.id,
device.id,
revisionNumber,
profile.profile_ref,
profile.schema_artifact_ref,
command.configurationDigest,
JSON.stringify(command.configuration),
command.changeSummary,
actor.userRef,
],
);
const revision = inserted.rows[0];
if (!revision) {
throw domainError("device_configuration_revision_insert_failed", 409);
}
await addAudit(client, {
eventType: "device_configuration_revision.created",
actorRef: actor.userRef,
projectId: project.id,
deviceId: device.id,
payload: {
deviceRef: `device:${device.id}`,
projectRef: toProjectRef(project.id),
configurationRevisionRef: `configuration-revision:${revision.id}`,
revisionNumber: Number(revision.revision_number),
modelProfileRef: revision.model_profile_ref,
schemaArtifactRef: revision.schema_artifact_ref,
configurationDigest: revision.configuration_digest,
},
});
return {
created: true,
configurationRevision: configurationRevisionView(revision),
};
}
async function setDesiredConfiguration(client, actor, command) {
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
"configuration.manage",
);
const device = await findDirectDeviceForUpdate(client, command);
const revisionResult = await client.query(
`select id, project_id, device_id, revision_number,
model_profile_ref, schema_artifact_ref, configuration_digest,
configuration, change_summary, created_at
from device_configuration_revisions
where id = $1 and device_id = $2 and project_id = $3
for share`,
[command.configurationRevisionId, device.id, project.id],
);
const revision = revisionResult.rows[0];
if (!revision) throw domainError("device_configuration_revision_not_found", 404);
const currentResult = await client.query(
`select desired_revision_id, applied_revision_id
from device_configuration_state
where device_id = $1
for update`,
[device.id],
);
const current = currentResult.rows[0] ?? null;
if (current?.desired_revision_id === revision.id) {
return {
changed: false,
configurationState: configurationStateView({
device_id: device.id,
project_id: project.id,
desired_revision_id: revision.id,
applied_revision_id: current.applied_revision_id,
}),
};
}
const stateResult = await client.query(
`insert into device_configuration_state (
device_id,
owner_scope_id,
project_id,
desired_revision_id
) values ($1, $2, $3, $4)
on conflict (device_id) do update set
owner_scope_id = excluded.owner_scope_id,
project_id = excluded.project_id,
desired_revision_id = excluded.desired_revision_id,
updated_at = now()
returning device_id, project_id, desired_revision_id, applied_revision_id`,
[device.id, project.owner_scope_id, project.id, revision.id],
);
const state = stateResult.rows[0];
if (!state) throw domainError("device_configuration_state_update_failed", 409);
await addAudit(client, {
eventType: "device_configuration.desired_changed",
actorRef: actor.userRef,
projectId: project.id,
deviceId: device.id,
payload: {
deviceRef: `device:${device.id}`,
projectRef: toProjectRef(project.id),
configurationRevisionRef: `configuration-revision:${revision.id}`,
previousConfigurationRevisionRef: current?.desired_revision_id
? `configuration-revision:${current.desired_revision_id}`
: null,
configurationDigest: revision.configuration_digest,
},
});
return {
changed: true,
configurationState: configurationStateView(state),
};
}
async function findBindingSource(client, command) {
if (command.source.kind === "device") {
return findDirectDeviceForUpdate(client, {
projectId: command.projectId,
deviceId: command.source.id,
});
}
const result = await client.query(
`select id, project_id, lifecycle_state
from device_collections
where id = $1 and project_id = $2
for share`,
[command.source.id, command.projectId],
);
const collection = result.rows[0];
if (!collection) throw domainError("device_collection_not_found", 404);
if (collection.lifecycle_state !== "active") {
throw domainError("device_collection_inactive", 409);
}
return collection;
}
async function findDirectDeviceForUpdate(client, command) {
const result = await client.query(
`select id, contour_id, owner_scope_id, project_id,
model_profile_ref, lifecycle_state
from device_instances
where id = $1
for update`,
[command.deviceId],
);
const device = result.rows[0];
if (!device) throw domainError("device_not_found", 404);
if (
device.contour_id
|| !device.owner_scope_id
|| !device.project_id
|| device.project_id !== command.projectId
) {
throw domainError("device_control_resource_project_mismatch", 409);
}
if (device.lifecycle_state === "retired") {
throw domainError("device_control_resource_lifecycle_blocked", 409);
}
return device;
}
async function addAudit(client, {
eventType,
actorRef,
projectId,
deviceId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id,
event_type,
actor_ref,
project_id,
device_id,
payload
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
[
randomUUID(),
eventType,
actorRef,
projectId,
deviceId,
JSON.stringify(payload),
],
);
}
function bindingView(row) {
return {
bindingRef: `binding:${row.id}`,
projectRef: toProjectRef(row.project_id),
bindingKey: row.binding_key,
displayName: row.display_name,
source: {
kind: row.source_kind,
ref: bindingSourceRef(row),
},
target: {
kind: row.target_kind,
ref: row.target_ref,
},
capabilities: row.capabilities ?? [],
lifecycleState: row.lifecycle_state,
sourceApprovedAt: toIso(row.source_approved_at),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function bindingSourceRef(row) {
return row.source_kind === "device"
? `device:${row.device_id}`
: `collection:${row.collection_id}`;
}
function configurationRevisionView(row) {
return {
configurationRevisionRef: `configuration-revision:${row.id}`,
deviceRef: `device:${row.device_id}`,
projectRef: toProjectRef(row.project_id),
revisionNumber: Number(row.revision_number),
modelProfileRef: row.model_profile_ref,
schemaArtifactRef: row.schema_artifact_ref,
configurationDigest: row.configuration_digest,
configuration: row.configuration,
changeSummary: row.change_summary ?? null,
createdAt: toIso(row.created_at),
};
}
function configurationStateView(row) {
return {
deviceRef: `device:${row.device_id}`,
projectRef: toProjectRef(row.project_id),
desiredConfigurationRevisionRef: row.desired_revision_id
? `configuration-revision:${row.desired_revision_id}`
: null,
appliedConfigurationRevisionRef: row.applied_revision_id
? `configuration-revision:${row.applied_revision_id}`
: null,
};
}
function toIso(value) {
return new Date(value).toISOString();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,29 +0,0 @@
export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials";
const CREDENTIAL_REFERENCE_PATTERN =
/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/;
export function normalizeNdcCredentialReference(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("ndc_credential_reference_invalid");
}
for (const key of Object.keys(input)) {
if (!new Set(["owner", "reference"]).has(key)) {
throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`);
}
}
if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) {
throw new TypeError("ndc_credential_reference_owner_invalid");
}
if (!isNdcCredentialReferenceValue(input.reference)) {
throw new TypeError("ndc_credential_reference_value_invalid");
}
return Object.freeze({
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
reference: input.reference,
});
}
export function isNdcCredentialReferenceValue(value) {
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
}
@@ -1,73 +0,0 @@
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;
}
@@ -1,741 +0,0 @@
import { randomUUID } from "node:crypto";
import { connect as connectHttp2 } from "node:http2";
import {
DEVICE_EDGE_CHANNEL_LIMITS,
DEVICE_EDGE_CHANNEL_PATH,
createChannelEnvelope,
createChannelEnvelopeDecoder,
encodeChannelEnvelope,
nextReconnectDelay,
normalizeCertificateIdentities,
normalizeCertificateFingerprint,
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
import {
DEVICE_DISCOVERY_VIEW_SCHEMA,
assertSafeProjection,
normalizeAdapterAcceptance,
normalizeAdapterMessage,
normalizeDiscoverySignal,
} from "../../../packages/device-protocol-contract/src/index.mjs";
// Runtime-owned transport implementation; kept inside the deployable Core context.
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
const CHANNEL_PROFILE_REF = "channel.control.v1";
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
export function createDeviceGatewayCoreChannelClient(options = {}) {
const config = normalizeConfig(options);
const readyWaiters = new Set();
let running = false;
let state = null;
let reconnectTimer = null;
let reconnectAttempt = 0;
let connectionSerial = 0;
let totalConnectionAttempts = 0;
let totalChannelsAccepted = 0;
let totalReconnects = 0;
let totalEventsAccepted = 0;
let totalEventsRejected = 0;
let totalProtocolFailures = 0;
let lastErrorCode = null;
return Object.freeze({
async start() {
if (running) return;
running = true;
void connectNow();
},
async stop() {
running = false;
clearTimeout(reconnectTimer);
reconnectTimer = null;
const current = state;
state = null;
if (current) closeConnection(current, false);
rejectReadyWaiters("device_gateway_core_channel_stopped");
},
waitForReady(timeoutMs = 5_000) {
if (state?.ready && !state.closed) return Promise.resolve(status());
const normalizedTimeout = normalizeInteger(
timeoutMs,
10,
120_000,
5_000,
"ready_timeout",
);
return new Promise((resolve, reject) => {
const waiter = { resolve, reject, timer: null };
waiter.timer = setTimeout(() => {
readyWaiters.delete(waiter);
reject(new Error("device_gateway_core_channel_ready_timeout"));
}, normalizedTimeout);
waiter.timer.unref?.();
readyWaiters.add(waiter);
});
},
status,
disconnect() {
if (state) closeConnection(state, true);
},
});
function status() {
return Object.freeze({
running,
channel: state?.ready ? "accepted" : state ? "connecting" : "absent",
edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null,
channelGeneration: state?.channelGeneration ?? null,
edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null,
edgeCertificateFingerprint:
state?.observedEdgeIdentity?.fingerprint ?? null,
negotiatedCommandTransport: state?.negotiatedCommandTransport ?? null,
activeTrackerSessionChains: state?.sessionChains.size ?? 0,
connectionAttempts: totalConnectionAttempts,
channelsAccepted: totalChannelsAccepted,
reconnects: totalReconnects,
eventsAccepted: totalEventsAccepted,
eventsRejected: totalEventsRejected,
protocolFailures: totalProtocolFailures,
lastErrorCode,
trackerIngress: "remote-edge-only",
commandTransport: config.commandTransport,
});
}
async function connectNow() {
if (!running || state) return;
totalConnectionAttempts += 1;
const serial = ++connectionSerial;
let registration;
try {
registration = normalizeRegistration(await config.registrationProvider());
if (registration.lifecycleState !== "active") {
throw new Error("device_gateway_core_edge_registration_inactive");
}
} catch (error) {
lastErrorCode = safeErrorCode(error);
scheduleReconnect();
return;
}
const connection = {
serial,
registration,
session: null,
request: null,
decoder: createChannelEnvelopeDecoder({
direction: "edge-to-core",
maxEnvelopeBytes: config.maxEnvelopeBytes,
}),
sessionChains: new Map(),
trackerDevices: new Map(),
channelGeneration: null,
negotiatedCommandTransport: null,
observedEdgeIdentity: null,
edgeSequence: 0,
coreSequence: 0,
lastEdgeActivityAt: config.clock(),
connectTimer: null,
heartbeatTimer: null,
ready: false,
closed: false,
};
state = connection;
const endpoint = new URL(registration.endpoint);
const authority = `${endpoint.protocol}//${endpoint.host}`;
const session = connectHttp2(authority, {
key: config.tls.key,
cert: config.tls.cert,
ca: config.tls.ca,
minVersion: "TLSv1.3",
maxVersion: "TLSv1.3",
rejectUnauthorized: true,
servername: registration.servername,
ALPNProtocols: ["h2"],
settings: {
enablePush: false,
initialWindowSize: 1024 * 1024,
},
});
connection.session = session;
connection.connectTimer = setTimeout(() => {
failConnection(connection, new Error(
"device_gateway_core_channel_connect_timeout",
));
}, config.connectTimeoutMs);
connection.connectTimer.unref?.();
session.once("error", (error) => failConnection(connection, error));
session.once("close", () => closeConnection(connection, true));
session.once("connect", () => {
try {
verifyEdgePeer(connection);
openChannelStream(connection);
} catch (error) {
failConnection(connection, error);
}
});
}
function openChannelStream(connection) {
assertCurrent(connection);
const request = connection.session.request({
":method": "POST",
":path": DEVICE_EDGE_CHANNEL_PATH,
"content-type": "application/x-ndjson",
"cache-control": "no-store",
}, { endStream: false });
connection.request = request;
request.once("response", (headers) => {
if (Number(headers[":status"]) !== 200) {
failConnection(connection, new Error(
`device_gateway_core_channel_http_status_${headers[":status"]}`,
));
}
});
let processing = Promise.resolve();
request.on("data", (chunk) => {
request.pause();
processing = processing
.then(async () => {
const envelopes = connection.decoder.push(chunk);
for (const envelope of envelopes) {
await handleEdgeEnvelope(connection, envelope);
}
})
.catch((error) => failConnection(connection, error))
.finally(() => {
if (!connection.closed) request.resume();
});
});
request.once("aborted", () => closeConnection(connection, true));
request.once("close", () => closeConnection(connection, true));
request.once("error", (error) => failConnection(connection, error));
connection.heartbeatTimer = setInterval(
() => checkChannelHealth(connection),
config.keepaliveMs,
);
connection.heartbeatTimer.unref?.();
}
async function handleEdgeEnvelope(connection, envelope) {
assertCurrent(connection);
if (
envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId
|| envelope.sequence !== connection.edgeSequence + 1
) {
throw new Error("device_gateway_core_edge_envelope_mismatch");
}
if (
connection.channelGeneration
&& envelope.channelGeneration !== connection.channelGeneration
) {
throw new Error("device_gateway_core_channel_generation_mismatch");
}
connection.edgeSequence = envelope.sequence;
connection.lastEdgeActivityAt = config.clock();
if (!connection.ready) {
if (envelope.messageKind !== "channel.hello") {
throw new Error("device_gateway_core_channel_hello_required");
}
if (
envelope.channelGeneration !== connection.registration.channelGeneration
) {
throw new Error("device_gateway_core_channel_generation_mismatch");
}
if (
envelope.payload?.status !== "ready"
|| envelope.payload?.transport !== "http2-mtls"
|| envelope.payload?.trustGeneration
!== connection.observedEdgeIdentity?.generationRef
|| !isCompatibleCommandTransport(
config.commandTransport,
envelope.payload?.commandTransport,
)
) {
throw new Error("device_gateway_core_channel_hello_invalid");
}
connection.negotiatedCommandTransport = envelope.payload.commandTransport;
connection.channelGeneration = connection.registration.channelGeneration;
send(connection, "channel.accepted", {
status: "accepted",
coreIdentity: config.coreIdentity,
commandTransport: connection.negotiatedCommandTransport,
}, {
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
adapterProfileRef: CHANNEL_PROFILE_REF,
correlationId: envelope.correlationId,
});
connection.ready = true;
clearTimeout(connection.connectTimer);
connection.connectTimer = null;
reconnectAttempt = 0;
totalChannelsAccepted += 1;
lastErrorCode = null;
resolveReadyWaiters();
return;
}
if (envelope.messageKind === "channel.heartbeat") return;
if (["discovery.observed", "adapter.message", "command.status"].includes(envelope.messageKind)) {
scheduleTrackerEvent(connection, envelope);
return;
}
throw new Error("device_gateway_core_edge_message_unhandled");
}
function scheduleTrackerEvent(connection, envelope) {
if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) {
throw new Error("device_gateway_core_tracker_session_invalid");
}
const previous = connection.sessionChains.get(envelope.trackerSessionId);
if (!previous && connection.sessionChains.size >= 128) {
throw new Error("device_gateway_core_tracker_session_limit_reached");
}
const work = (previous ?? Promise.resolve())
.then(() => envelope.messageKind === "discovery.observed"
? acceptDiscovery(connection, envelope)
: envelope.messageKind === "adapter.message"
? acceptAdapterMessage(connection, envelope)
: acceptCommandStatus(connection, envelope))
.catch((error) => failConnection(connection, error))
.finally(() => {
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
connection.sessionChains.delete(envelope.trackerSessionId);
}
});
connection.sessionChains.set(envelope.trackerSessionId, work);
}
async function acceptDiscovery(connection, envelope) {
try {
const signal = normalizeDiscoverySignal(envelope.payload?.signal);
const receipt = normalizeDiscoveryReceipt(
await config.observeDiscovery(signal),
);
if (receipt.claimedDeviceRef) {
connection.trackerDevices.set(
envelope.trackerSessionId,
receipt.claimedDeviceRef,
);
} else {
connection.trackerDevices.delete(envelope.trackerSessionId);
}
const commandOffer = (
connection.negotiatedCommandTransport === "typed-service-ping-v1"
&& receipt.claimedDeviceRef
)
? await config.offerCommand(receipt.claimedDeviceRef)
: null;
sendEventResult(connection, envelope, {
discovery: receipt.discovery,
...(commandOffer ? { commandOffer } : {}),
});
totalEventsAccepted += 1;
} catch (error) {
sendEventRejection(connection, envelope, error);
totalEventsRejected += 1;
}
}
async function acceptAdapterMessage(connection, envelope) {
try {
const message = normalizeAdapterMessage(envelope.payload?.message, {
maxBytes: config.maxEnvelopeBytes,
});
const receipt = normalizeAdapterReceipt(
await config.acceptMessage(message),
);
if (receipt.claimedDeviceRef) {
connection.trackerDevices.set(
envelope.trackerSessionId,
receipt.claimedDeviceRef,
);
}
const claimedDeviceRef = receipt.claimedDeviceRef
?? connection.trackerDevices.get(envelope.trackerSessionId);
const commandOffer = (
connection.negotiatedCommandTransport === "typed-service-ping-v1"
&& claimedDeviceRef
)
? await config.offerCommand(claimedDeviceRef)
: null;
sendEventResult(connection, envelope, {
acceptance: receipt.acceptance,
...(commandOffer ? { commandOffer } : {}),
});
totalEventsAccepted += 1;
} catch (error) {
sendEventRejection(connection, envelope, error);
totalEventsRejected += 1;
}
}
async function acceptCommandStatus(connection, envelope) {
try {
await config.recordCommandStatus(envelope.payload?.status);
sendEventResult(connection, envelope, { status: "recorded" });
totalEventsAccepted += 1;
} catch (error) {
sendEventRejection(connection, envelope, error);
totalEventsRejected += 1;
}
}
function sendEventResult(connection, envelope, result) {
send(connection, "event.accepted", { result }, {
trackerSessionId: envelope.trackerSessionId,
adapterProfileRef: envelope.adapterProfileRef,
correlationId: envelope.correlationId,
});
}
function sendEventRejection(connection, envelope, error) {
send(connection, "event.rejected", {
errorCode: safeErrorCode(error),
}, {
trackerSessionId: envelope.trackerSessionId,
adapterProfileRef: envelope.adapterProfileRef,
correlationId: envelope.correlationId,
});
}
function send(connection, messageKind, payload, metadata) {
assertCurrent(connection);
if (!connection.channelGeneration) {
throw new Error("device_gateway_core_channel_generation_absent");
}
connection.coreSequence += 1;
const now = config.now();
const envelope = createChannelEnvelope({
edgeRegistrationId: connection.registration.edgeRegistrationId,
channelGeneration: connection.channelGeneration,
trackerSessionId: metadata.trackerSessionId,
adapterProfileRef: metadata.adapterProfileRef,
sequence: connection.coreSequence,
eventAt: metadata.eventAt ?? now,
receivedAt: now,
messageKind,
correlationId: metadata.correlationId,
payload,
}, {
direction: "core-to-edge",
maxEnvelopeBytes: config.maxEnvelopeBytes,
});
connection.request.write(encodeChannelEnvelope(envelope, {
direction: "core-to-edge",
maxEnvelopeBytes: config.maxEnvelopeBytes,
}));
}
function checkChannelHealth(connection) {
if (connection.closed || state !== connection) return;
if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) {
failConnection(connection, new Error("device_gateway_core_edge_dead_peer"));
return;
}
if (connection.ready) {
try {
send(connection, "channel.heartbeat", { status: "alive" }, {
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
adapterProfileRef: CHANNEL_PROFILE_REF,
correlationId: `correlation:${randomUUID()}`,
});
} catch (error) {
failConnection(connection, error);
}
}
}
function verifyEdgePeer(connection) {
const socket = connection.session.socket;
if (!socket?.authorized || socket.alpnProtocol !== "h2") {
throw new Error("device_gateway_core_edge_tls_unauthorized");
}
const observed = normalizeCertificateFingerprint(
socket.getPeerCertificate()?.fingerprint256,
);
const identity = connection.registration.certificateIdentities.find(
(candidate) => candidate.fingerprint === observed,
);
if (!identity) {
throw new Error("device_gateway_core_edge_identity_mismatch");
}
connection.observedEdgeIdentity = identity;
}
function failConnection(connection, error) {
if (connection.closed) return;
totalProtocolFailures += 1;
lastErrorCode = safeErrorCode(error);
closeConnection(connection, true);
}
function closeConnection(connection, reconnect) {
if (connection.closed) return;
connection.closed = true;
clearTimeout(connection.connectTimer);
connection.connectTimer = null;
clearInterval(connection.heartbeatTimer);
connection.heartbeatTimer = null;
connection.sessionChains.clear();
try {
connection.request?.close();
} catch {}
try {
connection.session?.close();
} catch {}
if (state === connection) state = null;
if (reconnect && running) scheduleReconnect();
}
function scheduleReconnect() {
if (!running || reconnectTimer || state) return;
const delay = nextReconnectDelay(reconnectAttempt, {
minimumMs: config.reconnectMinimumMs,
maximumMs: config.reconnectMaximumMs,
random: config.random,
});
reconnectAttempt += 1;
totalReconnects += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connectNow();
}, delay);
reconnectTimer.unref?.();
}
function assertCurrent(connection) {
if (!running || connection.closed || state !== connection) {
throw new Error("device_gateway_core_channel_unavailable");
}
}
function resolveReadyWaiters() {
const value = status();
for (const waiter of readyWaiters) {
clearTimeout(waiter.timer);
waiter.resolve(value);
}
readyWaiters.clear();
}
function rejectReadyWaiters(code) {
for (const waiter of readyWaiters) {
clearTimeout(waiter.timer);
waiter.reject(new Error(code));
}
readyWaiters.clear();
}
}
function normalizeDiscoveryReceipt(input) {
if (
!input
|| typeof input !== "object"
|| Array.isArray(input)
|| typeof input.created !== "boolean"
) {
throw new TypeError("device_gateway_core_discovery_receipt_invalid");
}
const discovery = assertSafeProjection(input.value);
if (
discovery.schemaVersion !== DEVICE_DISCOVERY_VIEW_SCHEMA
|| !["quarantine", "claimed"].includes(discovery.lifecycleState)
|| discovery.commandTransport !== "disabled"
) {
throw new TypeError("device_gateway_core_discovery_receipt_invalid");
}
return Object.freeze({
discovery,
claimedDeviceRef: input.claimedDeviceRef ?? null,
});
}
function normalizeAdapterReceipt(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("device_gateway_core_adapter_receipt_invalid");
}
const acceptanceValue = input.value ?? (
input.schemaVersion === "nodedc.device-adapter-acceptance.v1"
? input
: null
);
return Object.freeze({
acceptance: normalizeAdapterAcceptance(acceptanceValue),
claimedDeviceRef: input.claimedDeviceRef ?? null,
});
}
function normalizeConfig(options) {
if (typeof options.observeDiscovery !== "function") {
throw new TypeError("device_gateway_core_observe_discovery_invalid");
}
if (typeof options.acceptMessage !== "function") {
throw new TypeError("device_gateway_core_accept_message_invalid");
}
const commandTransport = options.commandTransport ?? "disabled";
if (!["disabled", "typed-service-ping-v1"].includes(commandTransport)) {
throw new TypeError("device_gateway_core_command_transport_invalid");
}
const offerCommand = options.offerCommand ?? (async () => null);
const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined);
if (typeof offerCommand !== "function" || typeof recordCommandStatus !== "function") {
throw new TypeError("device_gateway_core_command_runtime_invalid");
}
const registrationProvider = typeof options.registrationProvider === "function"
? options.registrationProvider
: async () => options.registration;
const tls = normalizeTls(options.tls);
const keepaliveMs = normalizeInteger(
options.keepaliveMs,
10,
120_000,
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs,
"keepalive",
);
const deadPeerMs = normalizeInteger(
options.deadPeerMs,
keepaliveMs * 2,
120_000,
DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs,
"dead_peer",
);
const reconnectMinimumMs = normalizeInteger(
options.reconnectMinimumMs,
10,
120_000,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
"reconnect_minimum",
);
const reconnectMaximumMs = normalizeInteger(
options.reconnectMaximumMs,
10,
120_000,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
"reconnect_maximum",
);
if (reconnectMaximumMs < reconnectMinimumMs) {
throw new TypeError("device_gateway_core_reconnect_range_invalid");
}
return Object.freeze({
registrationProvider,
tls,
coreIdentity: normalizeRef(options.coreIdentity, "core_identity"),
observeDiscovery: options.observeDiscovery,
acceptMessage: options.acceptMessage,
commandTransport,
offerCommand,
recordCommandStatus,
keepaliveMs,
deadPeerMs,
connectTimeoutMs: normalizeInteger(
options.connectTimeoutMs,
10,
120_000,
DEFAULT_CONNECT_TIMEOUT_MS,
"connect_timeout",
),
reconnectMinimumMs,
reconnectMaximumMs,
maxEnvelopeBytes: normalizeInteger(
options.maxEnvelopeBytes,
256,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
"max_envelope_bytes",
),
random: typeof options.random === "function" ? options.random : Math.random,
clock: typeof options.clock === "function" ? options.clock : Date.now,
now: typeof options.now === "function"
? () => new Date(options.now()).toISOString()
: () => new Date().toISOString(),
});
}
function normalizeRegistration(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("device_gateway_core_edge_registration_invalid");
}
let endpoint;
try {
endpoint = new URL(String(value.endpoint || ""));
} catch {
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
}
if (
endpoint.protocol !== "https:"
|| endpoint.username
|| endpoint.password
|| endpoint.pathname !== "/"
|| endpoint.search
|| endpoint.hash
) {
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
}
if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) {
throw new TypeError("device_gateway_core_edge_lifecycle_invalid");
}
const servername = String(value.servername || "");
if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) {
throw new TypeError("device_gateway_core_edge_servername_invalid");
}
return Object.freeze({
edgeRegistrationId: normalizeRef(
value.edgeRegistrationId,
"edge_registration_id",
),
channelGeneration: normalizeRef(
value.channelGeneration,
"channel_generation",
),
endpoint: endpoint.toString(),
servername,
certificateIdentities: normalizeCertificateIdentities(
value.certificateIdentities,
),
lifecycleState: value.lifecycleState,
});
}
function normalizeTls(value) {
if (!value || typeof value !== "object") {
throw new TypeError("device_gateway_core_channel_tls_invalid");
}
for (const key of ["key", "cert", "ca"]) {
if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) {
throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`);
}
}
return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca });
}
function safeErrorCode(error) {
const value = String(error?.message || error || "device_gateway_core_error")
.toLowerCase()
.replaceAll(/[^a-z0-9._:-]/g, "_")
.slice(0, 128);
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
? value
: "device_gateway_core_event_rejected";
}
function isCompatibleCommandTransport(configured, offered) {
if (offered === configured) return true;
return configured === "typed-service-ping-v1" && offered === "disabled";
}
function normalizeRef(value, field) {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
throw new TypeError(`device_gateway_core_${field}_invalid`);
}
return value;
}
function normalizeInteger(value, minimum, maximum, fallback, field) {
const number = value == null ? fallback : Number(value);
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
throw new TypeError(`device_gateway_core_${field}_invalid`);
}
return number;
}
@@ -1,307 +0,0 @@
import { randomUUID } from "node:crypto";
export async function observeQuarantineDiscovery({
pool,
identifierDigest,
safeView,
sessionRef,
routeRef = null,
}) {
if ((safeView.routeRef ?? null) !== routeRef) {
throw new TypeError("device_discovery_route_ref_mismatch");
}
const routeId = routeRef == null ? null : parseEntityRef(routeRef, "route");
const client = await pool.connect();
try {
await client.query("begin");
const route = routeId == null
? null
: await findActiveRoute(client, routeId, safeView);
const enrollment = route == null
? null
: await findMatchingEnrollment(client, {
route,
identifierDigest,
safeView,
});
const result = await client.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,
session_ref,
project_id,
route_id,
enrollment_intent_id
) values (
$1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb,
$9, $10, $11, $12
)
on conflict (identifier_kind, identifier_digest, model_profile_ref)
do update set
last_observed_at = case
when device_discoveries.lifecycle_state = 'claimed'
and excluded.route_id is distinct from device_discoveries.route_id
then device_discoveries.last_observed_at
else greatest(
device_discoveries.last_observed_at,
excluded.last_observed_at
)
end,
evidence = case
when device_discoveries.lifecycle_state = 'claimed'
and excluded.route_id is distinct from device_discoveries.route_id
then device_discoveries.evidence
else excluded.evidence
end,
session_ref = case
when device_discoveries.lifecycle_state = 'claimed'
and excluded.route_id is distinct from device_discoveries.route_id
then device_discoveries.session_ref
else excluded.session_ref
end,
project_id = case
when device_discoveries.lifecycle_state = 'claimed'
then device_discoveries.project_id
when excluded.enrollment_intent_id is not null
and (
device_discoveries.enrollment_intent_id is null
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
or device_discoveries.lifecycle_state in ('rejected', 'expired')
) then excluded.project_id
when device_discoveries.project_id is null
then excluded.project_id
else device_discoveries.project_id
end,
route_id = case
when device_discoveries.lifecycle_state = 'claimed'
then device_discoveries.route_id
when excluded.enrollment_intent_id is not null
and (
device_discoveries.enrollment_intent_id is null
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
or device_discoveries.lifecycle_state in ('rejected', 'expired')
) then excluded.route_id
when device_discoveries.route_id is null
then excluded.route_id
else device_discoveries.route_id
end,
enrollment_intent_id = case
when device_discoveries.lifecycle_state = 'claimed'
then device_discoveries.enrollment_intent_id
when excluded.enrollment_intent_id is not null
and (
device_discoveries.enrollment_intent_id is null
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
or device_discoveries.lifecycle_state in ('rejected', 'expired')
) then excluded.enrollment_intent_id
else device_discoveries.enrollment_intent_id
end,
lifecycle_state = case
when device_discoveries.lifecycle_state = 'claimed' then 'claimed'
when excluded.enrollment_intent_id is not null
and device_discoveries.lifecycle_state in ('rejected', 'expired')
then 'quarantine'
else device_discoveries.lifecycle_state
end,
resolution_code = case
when excluded.enrollment_intent_id is not null
and device_discoveries.lifecycle_state in ('rejected', 'expired')
then null
else device_discoveries.resolution_code
end,
resolved_at = case
when excluded.enrollment_intent_id is not null
and device_discoveries.lifecycle_state in ('rejected', 'expired')
then null
else device_discoveries.resolved_at
end,
resolved_by_ref = case
when excluded.enrollment_intent_id is not null
and device_discoveries.lifecycle_state in ('rejected', 'expired')
then null
else device_discoveries.resolved_by_ref
end,
updated_at = now()
returning id, lifecycle_state, model_profile_ref, protocol,
identifier_kind, identifier_masked, first_observed_at,
last_observed_at, evidence, project_id, route_id,
enrollment_intent_id, claimed_device_id, (xmax = 0) as created`,
[
randomUUID(),
safeView.identifier.kind,
identifierDigest,
safeView.identifier.masked,
safeView.modelProfileRef,
safeView.protocol,
safeView.observedAt,
JSON.stringify(safeView.evidence),
sessionRef,
route?.project_id ?? null,
route?.id ?? null,
enrollment?.id ?? null,
],
);
const row = result.rows[0];
if (
enrollment
&& row.enrollment_intent_id !== enrollment.id
) {
throw domainError("device_discovery_enrollment_conflict", 409);
}
if (enrollment && row.lifecycle_state === "quarantine") {
const observed = await client.query(
`update device_enrollment_intents
set lifecycle_state = 'observed',
observed_discovery_id = $2,
observed_at = greatest(coalesce(observed_at, $3), $3),
resolution_code = null,
resolved_at = null,
resolved_by_ref = null,
updated_at = now()
where id = $1
and lifecycle_state in ('pending', 'observed')
returning id`,
[enrollment.id, row.id, safeView.observedAt],
);
if (!observed.rows[0]) {
throw domainError("device_enrollment_not_observable", 409);
}
}
await client.query("commit");
return {
created: row.created === true,
value: discoveryView(row),
claimedDeviceRef: row.claimed_device_id
? `device:${row.claimed_device_id}`
: null,
};
} catch (error) {
await client.query("rollback").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function findActiveRoute(client, routeId, safeView) {
const result = await client.query(
`select id, project_id, model_profile_ref, protocol, lifecycle_state
from device_routes
where id = $1
for share`,
[routeId],
);
const route = result.rows[0];
if (!route) throw domainError("device_discovery_route_not_found", 404);
if (route.lifecycle_state !== "active") {
throw domainError("device_discovery_route_inactive", 409);
}
if (
route.model_profile_ref !== safeView.modelProfileRef
|| route.protocol !== safeView.protocol
) {
throw domainError("device_discovery_route_profile_mismatch", 409);
}
return route;
}
async function findMatchingEnrollment(client, {
route,
identifierDigest,
safeView,
}) {
await client.query(
`update device_enrollment_intents
set lifecycle_state = 'expired',
resolution_code = 'deadline_elapsed',
resolved_at = $6,
updated_at = now()
where project_id = $1
and route_id = $2
and model_profile_ref = $3
and expected_identifier_kind = $4
and expected_identifier_digest = $5
and lifecycle_state = 'pending'
and expires_at is not null
and expires_at <= $6`,
[
route.project_id,
route.id,
safeView.modelProfileRef,
safeView.identifier.kind,
identifierDigest,
safeView.observedAt,
],
);
const result = await client.query(
`select id, project_id, route_id, model_profile_ref, lifecycle_state
from device_enrollment_intents
where project_id = $1
and route_id = $2
and model_profile_ref = $3
and expected_identifier_kind = $4
and expected_identifier_digest = $5
and lifecycle_state in ('pending', 'observed')
and (expires_at is null or expires_at > $6)
for update`,
[
route.project_id,
route.id,
safeView.modelProfileRef,
safeView.identifier.kind,
identifierDigest,
safeView.observedAt,
],
);
if (result.rows.length > 1) {
throw domainError("device_enrollment_identity_ambiguous", 409);
}
return result.rows[0] ?? null;
}
function discoveryView(row) {
return {
schemaVersion: "nodedc.device.discovery-view.v1",
discoveryRef: `discovery:${row.id}`,
...(row.route_id ? { routeRef: `route:${row.route_id}` } : {}),
...(row.enrollment_intent_id
? { enrollmentIntentRef: `enrollment-intent:${row.enrollment_intent_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: row.evidence,
commandTransport: "disabled",
};
}
function parseEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,376 +0,0 @@
import { createHash, X509Certificate } from "node:crypto";
import { lstat, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
normalizeCertificateFingerprint,
normalizeCertificateIdentities,
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
import {
createDeviceGatewayCoreChannelClient,
} from "./device-gateway-core-runtime.mjs";
const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers";
export function createDeviceEdgeChannelSupervisor(options = {}) {
const config = normalizeConfiguration(options);
const clients = new Map();
const failures = new Map();
let running = false;
let timer = null;
let reconcilePromise = null;
let requestedCount = 0;
let reconciliationFailures = 0;
let lastErrorCode = null;
return Object.freeze({
async start() {
if (running) return;
running = true;
await reconcile();
schedule();
},
async stop() {
running = false;
clearTimeout(timer);
timer = null;
if (reconcilePromise) await reconcilePromise.catch(() => undefined);
const stopping = [...clients.values()].map(({ client }) => client.stop());
clients.clear();
failures.clear();
await Promise.allSettled(stopping);
},
reconcile,
status,
});
async function reconcile() {
if (!running) return status();
if (reconcilePromise) return reconcilePromise;
reconcilePromise = performReconcile().finally(() => {
reconcilePromise = null;
});
return reconcilePromise;
}
async function performReconcile() {
let registrations;
try {
registrations = await config.repository
.listActiveEdgeChannelRegistrations(config.maxEdges);
if (!Array.isArray(registrations) || registrations.length > config.maxEdges) {
throw new TypeError("device_edge_channel_registration_set_invalid");
}
registrations = registrations.map(normalizeRegistration);
if (new Set(registrations.map((item) => item.edgeRegistrationId)).size
!== registrations.length) {
throw new TypeError("device_edge_channel_registration_set_invalid");
}
requestedCount = registrations.length;
} catch (error) {
reconciliationFailures += 1;
lastErrorCode = safeErrorCode(error);
return status();
}
const desiredIds = new Set(registrations.map((item) => item.edgeRegistrationId));
for (const [edgeRegistrationId, active] of clients) {
if (!desiredIds.has(edgeRegistrationId)) {
clients.delete(edgeRegistrationId);
await active.client.stop().catch(() => undefined);
}
}
for (const edgeRegistrationId of failures.keys()) {
if (!desiredIds.has(edgeRegistrationId)) failures.delete(edgeRegistrationId);
}
for (const registration of registrations) {
const digest = registrationDigest(registration);
const current = clients.get(registration.edgeRegistrationId);
if (current?.digest === digest) {
failures.delete(registration.edgeRegistrationId);
continue;
}
if (current) {
clients.delete(registration.edgeRegistrationId);
await current.client.stop().catch(() => undefined);
}
try {
const ca = await config.readPeerTrust({
registration,
trustRoot: config.trustRoot,
});
const client = config.clientFactory({
registration,
tls: {
key: config.coreIdentity.key,
cert: config.coreIdentity.cert,
ca,
},
coreIdentity: config.coreIdentity.identityRef,
observeDiscovery: (signal) => config.gatewayIngest.observeDiscovery(
signal,
{ authenticatedEdgeRef: registration.edgeRegistrationId },
),
acceptMessage: (message) => config.gatewayIngest.acceptMessage(
message,
{ authenticatedEdgeRef: registration.edgeRegistrationId },
),
commandTransport: config.typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
offerCommand: config.typedCommandRuntime?.offerForDevice,
recordCommandStatus: config.typedCommandRuntime?.recordStatus,
});
assertClient(client);
clients.set(registration.edgeRegistrationId, { client, digest });
failures.delete(registration.edgeRegistrationId);
await client.start();
} catch (error) {
const code = safeErrorCode(error);
failures.set(registration.edgeRegistrationId, code);
lastErrorCode = code;
}
}
return status();
}
function schedule() {
if (!running) return;
timer = setTimeout(async () => {
timer = null;
await reconcile().catch(() => undefined);
schedule();
}, config.reconcileIntervalMs);
timer.unref?.();
}
function status() {
let accepted = 0;
let connecting = 0;
let degraded = failures.size;
const edges = [];
for (const [edgeRegistrationId, { client }] of clients) {
const clientStatus = client.status();
if (clientStatus.channel === "accepted") accepted += 1;
else connecting += 1;
if (clientStatus.lastErrorCode) degraded += 1;
edges.push(Object.freeze({
edgeRegistrationId,
channel: clientStatus.channel,
lastErrorCode: clientStatus.lastErrorCode ?? null,
}));
}
for (const [edgeRegistrationId, code] of failures) {
edges.push(Object.freeze({
edgeRegistrationId,
channel: "absent",
lastErrorCode: code,
}));
}
edges.sort((left, right) =>
left.edgeRegistrationId.localeCompare(right.edgeRegistrationId)
);
return Object.freeze({
enabled: true,
running,
configured: requestedCount,
accepted,
connecting,
degraded,
reconciliationFailures,
lastErrorCode,
commandTransport: config.typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
edges: Object.freeze(edges),
});
}
}
export async function readPinnedEdgeTrust({ registration, trustRoot }) {
const match = registration.trustBundleRef.match(
/^edge-trust:([a-z][a-z0-9-]{1,62})$/,
);
if (!match) throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
const root = resolve(trustRoot);
const path = resolve(root, `${match[1]}.pem`);
if (dirname(path) !== root) {
throw new TypeError("device_edge_channel_trust_bundle_path_invalid");
}
const state = await lstat(path);
if (state.isSymbolicLink() || !state.isFile() || state.size < 1 || state.size > 64 * 1024) {
throw new Error("device_edge_channel_trust_bundle_file_invalid");
}
const pem = await readFile(path);
const blocks = pem.toString("utf8").match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g,
);
if (!blocks || blocks.length < 1 || blocks.length > 2) {
throw new Error("device_edge_channel_trust_bundle_invalid");
}
const expected = new Set(
registration.certificateIdentities.map((item) => item.fingerprint),
);
const observed = new Set(blocks.map((block) => normalizeCertificateFingerprint(
new X509Certificate(block).fingerprint256,
)));
if (
observed.size !== expected.size
|| [...observed].some((fingerprint) => !expected.has(fingerprint))
) {
throw new Error("device_edge_channel_trust_bundle_identity_mismatch");
}
return pem;
}
function normalizeConfiguration(options) {
if (
!options.repository
|| typeof options.repository.listActiveEdgeChannelRegistrations !== "function"
) {
throw new TypeError("device_edge_channel_repository_required");
}
if (
!options.gatewayIngest
|| typeof options.gatewayIngest.observeDiscovery !== "function"
|| typeof options.gatewayIngest.acceptMessage !== "function"
) {
throw new TypeError("device_edge_channel_gateway_ingest_required");
}
const coreIdentity = options.coreIdentity;
if (
!coreIdentity
|| !(typeof coreIdentity.key === "string" || Buffer.isBuffer(coreIdentity.key))
|| !(typeof coreIdentity.cert === "string" || Buffer.isBuffer(coreIdentity.cert))
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(coreIdentity.identityRef)
) {
throw new TypeError("device_edge_channel_core_identity_invalid");
}
const maxEdges = normalizeInteger(options.maxEdges, 1, 64, 32);
const reconcileIntervalMs = normalizeInteger(
options.reconcileIntervalMs,
1_000,
300_000,
15_000,
);
const trustRoot = resolve(options.trustRoot ?? DEFAULT_TRUST_ROOT);
return Object.freeze({
repository: options.repository,
gatewayIngest: options.gatewayIngest,
typedCommandRuntime: normalizeTypedCommandRuntime(options.typedCommandRuntime),
coreIdentity: Object.freeze({ ...coreIdentity }),
maxEdges,
reconcileIntervalMs,
trustRoot,
readPeerTrust: options.readPeerTrust ?? readPinnedEdgeTrust,
clientFactory: options.clientFactory ?? createDeviceGatewayCoreChannelClient,
});
}
function normalizeTypedCommandRuntime(value) {
if (value == null) return null;
if (
typeof value.offerForDevice !== "function"
|| typeof value.recordStatus !== "function"
) {
throw new TypeError("device_edge_channel_typed_command_runtime_invalid");
}
return value;
}
function normalizeRegistration(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("device_edge_channel_registration_invalid");
}
const endpoint = new URL(String(value.endpoint || ""));
if (
endpoint.protocol !== "https:"
|| endpoint.username
|| endpoint.password
|| endpoint.pathname !== "/"
|| endpoint.search
|| endpoint.hash
|| endpoint.port !== ""
|| endpoint.hostname !== String(value.servername || "").toLowerCase()
|| !isPublicIpv4(endpoint.hostname)
) {
throw new TypeError("device_edge_channel_registration_endpoint_invalid");
}
if (value.lifecycleState !== "active") {
throw new TypeError("device_edge_channel_registration_inactive");
}
return Object.freeze({
edgeRegistrationId: normalizeRef(value.edgeRegistrationId),
endpoint: endpoint.toString(),
servername: endpoint.hostname,
channelGeneration: normalizeRef(value.channelGeneration),
trustBundleRef: normalizeTrustRef(value.trustBundleRef),
certificateIdentities: normalizeCertificateIdentities(
value.certificateIdentities,
),
lifecycleState: "active",
});
}
function normalizeRef(value) {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
throw new TypeError("device_edge_channel_registration_ref_invalid");
}
return value;
}
function normalizeTrustRef(value) {
if (typeof value !== "string" || !/^edge-trust:[a-z][a-z0-9-]{1,62}$/.test(value)) {
throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
}
return value;
}
function isPublicIpv4(value) {
const octets = value.split(".").map(Number);
if (octets.length !== 4 || octets.some((item) =>
!Number.isInteger(item) || item < 0 || item > 255
)) return false;
const [a, b, c] = octets;
return a >= 1 && a < 224
&& a !== 10 && a !== 127
&& !(a === 100 && b >= 64 && b <= 127)
&& !(a === 169 && b === 254)
&& !(a === 172 && b >= 16 && b <= 31)
&& !(a === 192 && (b === 0 || b === 168))
&& !(a === 192 && b === 88 && c === 99)
&& !(a === 198 && (b === 18 || b === 19 || b === 51))
&& !(a === 203 && b === 0 && c === 113);
}
function registrationDigest(value) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function assertClient(value) {
if (
!value
|| typeof value.start !== "function"
|| typeof value.stop !== "function"
|| typeof value.status !== "function"
) throw new TypeError("device_edge_channel_client_invalid");
}
function normalizeInteger(value, minimum, maximum, fallback) {
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new TypeError("device_edge_channel_integer_invalid");
}
return parsed;
}
function safeErrorCode(error) {
const value = String(error?.message || error || "device_edge_channel_error")
.toLowerCase()
.replaceAll(/[^a-z0-9._:-]/g, "_")
.slice(0, 128);
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
? value
: "device_edge_channel_error";
}
@@ -1,125 +0,0 @@
import { createHash } from "node:crypto";
import {
assertSafeProjection,
hashRestrictedIdentifier,
normalizeAdapterAcceptance,
normalizeAdapterMessage,
normalizeDiscoverySignal,
toSafeAdapterMessageView,
toSafeDiscoveryView,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createDeviceGatewayIngest({ repository, identifierPepper } = {}) {
if (!repository || typeof repository.upsertQuarantineDiscovery !== "function") {
throw new TypeError("device_discovery_repository_required");
}
if (typeof repository.acceptAdapterMessage !== "function") {
throw new TypeError("device_gateway_message_repository_required");
}
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
throw new TypeError("device_identifier_pepper_invalid");
}
return Object.freeze({
async observeDiscovery(input, context = {}) {
const receivedSignal = normalizeDiscoverySignal(input);
const identifierDigest = hashRestrictedIdentifier(
receivedSignal.identifier,
identifierPepper,
);
const routeRef = await resolveAuthenticatedRoute(repository, {
edgeRef: context.authenticatedEdgeRef,
modelProfileRef: receivedSignal.modelProfileRef,
protocol: receivedSignal.protocol,
identifierKind: receivedSignal.identifier.kind,
identifierDigest,
observedAt: receivedSignal.observedAt,
});
const signal = routeRef === undefined
? receivedSignal
: normalizeDiscoverySignal({
...withoutKeys(receivedSignal, ["routeRef"]),
...(routeRef ? { routeRef } : {}),
});
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
const discovery = await repository.upsertQuarantineDiscovery({
identifierDigest,
safeView,
sessionRef: signal.sessionRef,
routeRef: signal.routeRef ?? null,
});
return Object.freeze({
created: discovery.created === true,
value: assertSafeProjection(discovery.value),
claimedDeviceRef: discovery.claimedDeviceRef ?? null,
});
},
async acceptMessage(input, context = {}) {
const receivedMessage = normalizeAdapterMessage(input);
const identifierDigest = hashRestrictedIdentifier(
receivedMessage.identifier,
identifierPepper,
);
const routeRef = await resolveAuthenticatedRoute(repository, {
edgeRef: context.authenticatedEdgeRef,
modelProfileRef: receivedMessage.protocolProfileRef,
protocol: receivedMessage.protocol,
identifierKind: receivedMessage.identifier.kind,
identifierDigest,
observedAt: receivedMessage.observedAt,
});
const message = routeRef === undefined
? receivedMessage
: normalizeAdapterMessage({
...withoutKeys(receivedMessage, ["edgeRef", "routeRef"]),
edgeRef: context.authenticatedEdgeRef,
...(routeRef ? { routeRef } : {}),
});
const safeView = assertSafeProjection(toSafeAdapterMessageView(message));
const requestDigest = gatewayMessageRequestDigest({
edgeRef: safeView.edgeRef,
adapterRef: safeView.adapterRef,
protocolProfileRef: safeView.protocolProfileRef,
protocol: safeView.protocol,
routeRef: safeView.routeRef ?? null,
idempotencyKey: safeView.idempotencyKey,
identifierKind: safeView.identifier.kind,
identifierDigest,
payloadSchemaRef: safeView.payloadSchemaRef,
payload: safeView.payload,
});
const receipt = await repository.acceptAdapterMessage({
identifierDigest,
requestDigest,
safeView,
});
return Object.freeze({
value: normalizeAdapterAcceptance(receipt.acceptance),
claimedDeviceRef: receipt.claimedDeviceRef ?? null,
});
},
});
}
async function resolveAuthenticatedRoute(repository, input) {
if (input.edgeRef == null) return undefined;
if (typeof repository.resolveInboundRoute !== "function") {
throw new TypeError("device_inbound_route_repository_required");
}
return repository.resolveInboundRoute(input);
}
function withoutKeys(value, keys) {
const omitted = new Set(keys);
return Object.fromEntries(
Object.entries(value).filter(([key]) => !omitted.has(key)),
);
}
function gatewayMessageRequestDigest(value) {
return `sha256:${createHash("sha256")
.update(JSON.stringify(value), "utf8")
.digest("hex")}`;
}
@@ -1,203 +0,0 @@
import { randomUUID } from "node:crypto";
export async function acceptGatewayMessage({
pool,
identifierDigest,
requestDigest,
safeView,
}) {
const routeId = safeView.routeRef == null
? null
: parseEntityRef(safeView.routeRef, "route");
const client = await pool.connect();
try {
await client.query("begin");
const route = routeId == null
? null
: await findActiveRoute(client, routeId, safeView);
const claimedDeviceRef = route == null
? null
: await findClaimedDeviceRef(client, {
identifierDigest,
route,
safeView,
});
const id = randomUUID();
const inserted = await client.query(
`insert into device_gateway_message_receipts (
id,
idempotency_key,
request_digest,
edge_ref,
adapter_ref,
protocol_profile_ref,
protocol,
route_id,
project_id,
session_ref,
message_ref,
message_type,
sequence,
identifier_kind,
identifier_digest,
identifier_masked,
payload_schema_ref,
payload,
observed_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19
)
on conflict (idempotency_key) do nothing
returning id, idempotency_key, accepted_at`,
[
id,
safeView.idempotencyKey,
requestDigest,
safeView.edgeRef,
safeView.adapterRef,
safeView.protocolProfileRef,
safeView.protocol,
route?.id ?? null,
route?.project_id ?? null,
safeView.sessionRef,
safeView.messageRef,
safeView.messageType,
safeView.sequence,
safeView.identifier.kind,
identifierDigest,
safeView.identifier.masked,
safeView.payloadSchemaRef,
JSON.stringify(safeView.payload),
safeView.observedAt,
],
);
if (inserted.rows[0]) {
await client.query("commit");
return receiptView(inserted.rows[0], false, claimedDeviceRef);
}
const existing = await client.query(
`select id, idempotency_key, request_digest, accepted_at
from device_gateway_message_receipts
where idempotency_key = $1
for share`,
[safeView.idempotencyKey],
);
const row = existing.rows[0];
if (!row) throw domainError("device_gateway_receipt_missing", 409);
if (row.request_digest !== requestDigest) {
throw domainError("device_gateway_idempotency_conflict", 409);
}
await client.query("commit");
return receiptView(row, true, claimedDeviceRef);
} catch (error) {
await client.query("rollback").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function findClaimedDeviceRef(client, {
identifierDigest,
route,
safeView,
}) {
const result = await client.query(
`select claimed_device_id
from device_discoveries
where identifier_kind = $1
and identifier_digest = $2
and model_profile_ref = $3
and lifecycle_state = 'claimed'
and project_id = $4
and route_id = $5
and claimed_device_id is not null
for share`,
[
safeView.identifier.kind,
identifierDigest,
safeView.protocolProfileRef,
route.project_id,
route.id,
],
);
const row = result.rows[0];
return row?.claimed_device_id
? `device:${row.claimed_device_id}`
: null;
}
async function findActiveRoute(client, routeId, safeView) {
const edgeId = parseEntityRef(safeView.edgeRef, "edge");
const result = await client.query(
`select r.id, r.project_id, r.edge_id, r.model_profile_ref,
r.protocol, r.lifecycle_state,
e.lifecycle_state as edge_lifecycle_state,
p.lifecycle_state as profile_lifecycle_state,
ap.package_key as adapter_ref,
ap.lifecycle_state as adapter_lifecycle_state,
av.lifecycle_state as adapter_version_lifecycle_state
from device_routes r
join device_edges e on e.id = r.edge_id
join device_model_profiles p on p.profile_ref = r.model_profile_ref
join device_adapter_versions av on av.id = p.adapter_version_id
join device_adapter_packages ap on ap.id = av.adapter_package_id
where r.id = $1
for share`,
[routeId],
);
const route = result.rows[0];
if (!route) throw domainError("device_gateway_route_not_found", 404);
if (
route.lifecycle_state !== "active"
|| route.edge_lifecycle_state !== "active"
|| route.profile_lifecycle_state !== "active"
|| route.adapter_lifecycle_state !== "active"
|| route.adapter_version_lifecycle_state !== "active"
) {
throw domainError("device_gateway_route_not_active", 409);
}
if (
route.edge_id !== edgeId
|| route.model_profile_ref !== safeView.protocolProfileRef
|| route.protocol !== safeView.protocol
|| route.adapter_ref !== safeView.adapterRef
) {
throw domainError("device_gateway_route_contract_mismatch", 409);
}
return route;
}
function receiptView(row, replayed, claimedDeviceRef) {
return {
acceptance: {
schemaVersion: "nodedc.device-adapter-acceptance.v1",
acceptanceRef: `acceptance:${row.id}`,
idempotencyKey: row.idempotency_key,
status: "accepted",
replayed,
acceptedAt: new Date(row.accepted_at).toISOString(),
},
claimedDeviceRef,
};
}
function parseEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,111 +0,0 @@
export async function resolveInboundRoute(client, input = {}) {
if (!client || typeof client.query !== "function") {
throw new TypeError("device_inbound_route_client_required");
}
const edgeId = parseEntityRef(input.edgeRef, "edge");
const modelProfileRef = normalizeOpaqueRef(
input.modelProfileRef,
"model_profile_ref",
);
const protocol = normalizeUpperToken(input.protocol, "protocol");
const identifierKind = normalizeLowerToken(
input.identifierKind,
"identifier_kind",
);
const identifierDigest = normalizeIdentifierDigest(input.identifierDigest);
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
const result = await client.query(
`select r.id
from device_enrollment_intents ei
join device_routes r
on r.id = ei.route_id
and r.project_id = ei.project_id
and r.model_profile_ref = ei.model_profile_ref
join device_edges e on e.id = r.edge_id
where r.edge_id = $1
and r.model_profile_ref = $2
and r.protocol = $3
and r.lifecycle_state = 'active'
and e.lifecycle_state = 'active'
and e.channel_lifecycle_state = 'active'
and ei.expected_identifier_kind = $4
and ei.expected_identifier_digest = $5
and ei.lifecycle_state in ('pending', 'observed', 'claimed')
and (
ei.lifecycle_state = 'claimed'
or ei.expires_at is null
or ei.expires_at > $6
)
order by r.id
limit 2`,
[
edgeId,
modelProfileRef,
protocol,
identifierKind,
identifierDigest,
observedAt,
],
);
if (result.rows.length > 1) {
throw domainError("device_inbound_route_ambiguous", 409);
}
return result.rows[0]?.id ? `route:${result.rows[0].id}` : null;
}
function parseEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizeOpaqueRef(value, name) {
if (
typeof value !== "string"
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)
) {
throw new TypeError(`device_inbound_route_${name}_invalid`);
}
return value;
}
function normalizeUpperToken(value, name) {
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
throw new TypeError(`device_inbound_route_${name}_invalid`);
}
return value;
}
function normalizeLowerToken(value, name) {
if (typeof value !== "string" || !/^[a-z][a-z0-9._:-]{1,63}$/.test(value)) {
throw new TypeError(`device_inbound_route_${name}_invalid`);
}
return value;
}
function normalizeIdentifierDigest(value) {
if (typeof value !== "string" || !/^hmac-sha256:[a-f0-9]{64}$/.test(value)) {
throw new TypeError("device_inbound_route_identifier_digest_invalid");
}
return value;
}
function normalizeTimestamp(value, name) {
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
throw new TypeError(`device_inbound_route_${name}_invalid`);
}
return value;
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,505 +0,0 @@
import {
assertIdentifierDigest,
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import {
normalizeCertificateIdentities,
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
import { normalizeManagementActor } from "./project-management.mjs";
export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([
"adapter_package.ensure",
"adapter_version.register",
"model_profile.register",
"edge.ensure",
"route.ensure",
"enrollment_intent.ensure",
]);
const commandKindSet = new Set(DEVICE_INFRASTRUCTURE_COMMAND_KINDS);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const profileRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
const protocolPattern = /^[A-Z][A-Z0-9_]{0,31}$/;
const capabilityPattern = /^[a-z][a-z0-9._-]{1,63}$/;
const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9.-]+)?$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
export function isInfrastructureManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeInfrastructureManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_infrastructure_command_kind_invalid");
}
assertPlainObject(input, "device_infrastructure_command_invalid");
if (kind === "adapter_package.ensure") {
assertAllowedKeys(input, [
"packageKey",
"displayName",
"publisherRef",
"lifecycleState",
]);
return Object.freeze({
packageKey: normalizeKey(input.packageKey, "device_adapter_package_key_invalid"),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_adapter_package_name_invalid",
),
publisherRef: normalizeOpaqueRef(
input.publisherRef,
"device_adapter_publisher_ref_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "retired"]),
"device_adapter_package_state_invalid",
),
});
}
if (kind === "adapter_version.register") {
assertAllowedKeys(input, [
"adapterPackageRef",
"version",
"runtimePackageRef",
"contentDigest",
"contractVersion",
"capabilities",
"lifecycleState",
]);
return Object.freeze({
adapterPackageId: normalizeEntityRef(
input.adapterPackageRef,
"adapter-package",
"device_adapter_package_ref_invalid",
),
version: normalizePattern(
input.version,
semverPattern,
"device_adapter_version_invalid",
),
runtimePackageRef: normalizeOpaqueRef(
input.runtimePackageRef,
"device_adapter_runtime_package_ref_invalid",
),
contentDigest: normalizePattern(
input.contentDigest,
digestPattern,
"device_adapter_content_digest_invalid",
),
contractVersion: normalizeProfileRef(
input.contractVersion,
"device_adapter_contract_version_invalid",
),
capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "draft",
new Set(["draft", "active", "retired"]),
"device_adapter_version_state_invalid",
),
});
}
if (kind === "model_profile.register") {
assertAllowedKeys(input, [
"adapterVersionRef",
"profileRef",
"schemaVersion",
"vendor",
"model",
"deviceType",
"protocol",
"schemaArtifactRef",
"profileDigest",
"capabilities",
"lifecycleState",
]);
return Object.freeze({
adapterVersionId: normalizeEntityRef(
input.adapterVersionRef,
"adapter-version",
"device_adapter_version_ref_invalid",
),
profileRef: normalizeProfileRef(
input.profileRef,
"device_model_profile_ref_invalid",
),
schemaVersion: normalizeProfileRef(
input.schemaVersion,
"device_model_profile_schema_version_invalid",
),
vendor: normalizeDisplayText(input.vendor, 120, "device_model_vendor_invalid"),
model: normalizeDisplayText(input.model, 120, "device_model_name_invalid"),
deviceType: normalizePattern(
input.deviceType,
capabilityPattern,
"device_model_type_invalid",
),
protocol: normalizePattern(
input.protocol,
protocolPattern,
"device_model_protocol_invalid",
),
schemaArtifactRef: normalizeOpaqueRef(
input.schemaArtifactRef,
"device_model_schema_artifact_ref_invalid",
),
profileDigest: normalizePattern(
input.profileDigest,
digestPattern,
"device_model_profile_digest_invalid",
),
capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "draft",
new Set(["draft", "active", "retired"]),
"device_model_profile_state_invalid",
),
});
}
if (kind === "edge.ensure") {
assertAllowedKeys(input, [
"edgeKey",
"displayName",
"deploymentRef",
"lifecycleState",
"channel",
]);
const normalized = {
edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_edge_name_invalid",
),
deploymentRef: normalizeOptionalOpaqueRef(
input.deploymentRef,
"device_edge_deployment_ref_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "suspended", "retired"]),
"device_edge_state_invalid",
),
};
if (input.channel !== undefined) {
normalized.channel = normalizeEdgeChannel(input.channel);
}
return Object.freeze(normalized);
}
if (kind === "route.ensure") {
assertAllowedKeys(input, [
"projectRef",
"routeKey",
"displayName",
"edgeRef",
"modelProfileRef",
"listenerRef",
"protocol",
"direction",
"lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(
input.projectRef,
"project",
"device_project_ref_invalid",
),
routeKey: normalizeKey(input.routeKey, "device_route_key_invalid"),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_route_name_invalid",
),
edgeId: normalizeEntityRef(
input.edgeRef,
"edge",
"device_edge_ref_invalid",
),
modelProfileRef: normalizeProfileRef(
input.modelProfileRef,
"device_model_profile_ref_invalid",
),
listenerRef: normalizeOpaqueRef(
input.listenerRef,
"device_route_listener_ref_invalid",
),
protocol: normalizePattern(
input.protocol,
protocolPattern,
"device_route_protocol_invalid",
),
direction: normalizeEnum(
input.direction ?? "telemetry",
new Set(["telemetry", "bidirectional"]),
"device_route_direction_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "draft",
new Set(["draft", "active", "suspended", "retired"]),
"device_route_state_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef",
"enrollmentKey",
"routeRef",
"modelProfileRef",
"displayName",
"identifierKind",
"identifierDigest",
"identifierMasked",
"expiresAt",
]);
const identifierMasked = normalizeDisplayText(
input.identifierMasked,
64,
"device_enrollment_identifier_masked_invalid",
);
assertSafeProjection({ identifierMasked });
return Object.freeze({
projectId: normalizeEntityRef(
input.projectRef,
"project",
"device_project_ref_invalid",
),
enrollmentKey: normalizeKey(
input.enrollmentKey,
"device_enrollment_key_invalid",
),
routeId: normalizeEntityRef(
input.routeRef,
"route",
"device_route_ref_invalid",
),
modelProfileRef: normalizeProfileRef(
input.modelProfileRef,
"device_model_profile_ref_invalid",
),
displayName: normalizeDisplayText(
input.displayName,
160,
"device_enrollment_name_invalid",
),
identifierKind: normalizePattern(
input.identifierKind,
/^[a-z][a-z0-9._-]{1,31}$/,
"device_enrollment_identifier_kind_invalid",
),
identifierDigest: assertIdentifierDigest(input.identifierDigest),
identifierMasked,
expiresAt: normalizeOptionalTimestamp(input.expiresAt),
});
}
export function assertPlatformCatalogAuthority(actorInput) {
const actor = normalizeManagementActor(actorInput);
if (actor.hubRole !== "owner") {
throw domainError("device_platform_catalog_access_denied", 403);
}
return actor;
}
function normalizeCapabilities(input) {
if (!Array.isArray(input) || input.length > 64) {
throw new TypeError("device_adapter_capabilities_invalid");
}
return [...new Set(input.map((capability) => normalizePattern(
capability,
capabilityPattern,
"device_adapter_capability_invalid",
)))].sort();
}
function normalizeEntityRef(value, prefix, code) {
if (typeof value !== "string") throw new TypeError(code);
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(code);
return match[1].toLowerCase();
}
function normalizeKey(value, code) {
return normalizePattern(value, keyPattern, code);
}
function normalizeProfileRef(value, code) {
return normalizePattern(value, profileRefPattern, code);
}
function normalizeOpaqueRef(value, code) {
return normalizePattern(value, opaqueRefPattern, code);
}
function normalizeOptionalOpaqueRef(value, code) {
if (value == null || value === "") return null;
return normalizeOpaqueRef(value, code);
}
function normalizeEdgeChannel(input) {
assertPlainObject(input, "device_edge_channel_invalid");
assertAllowedKeys(input, [
"endpoint",
"servername",
"generationRef",
"trustBundleRef",
"certificateIdentities",
"lifecycleState",
]);
const lifecycleState = normalizeEnum(
input.lifecycleState ?? "disabled",
new Set(["disabled", "active", "revoked"]),
"device_edge_channel_state_invalid",
);
if (lifecycleState === "disabled") {
if (Object.keys(input).some((key) => key !== "lifecycleState")) {
throw new TypeError("device_edge_channel_disabled_configuration_invalid");
}
return Object.freeze({
endpoint: null,
servername: null,
generationRef: null,
trustBundleRef: null,
certificateIdentities: Object.freeze([]),
lifecycleState,
});
}
const endpoint = normalizeEdgeEndpoint(input.endpoint);
const servername = normalizePattern(
input.servername,
/^[A-Za-z0-9.-]{1,253}$/,
"device_edge_channel_servername_invalid",
).toLowerCase();
if (servername !== endpoint.hostname) {
throw new TypeError("device_edge_channel_servername_mismatch");
}
return Object.freeze({
endpoint: endpoint.toString(),
servername,
generationRef: normalizeProfileRef(
input.generationRef,
"device_edge_channel_generation_invalid",
),
trustBundleRef: normalizePattern(
input.trustBundleRef,
/^edge-trust:[a-z][a-z0-9-]{1,62}$/,
"device_edge_channel_trust_bundle_ref_invalid",
),
certificateIdentities: normalizeCertificateIdentities(
input.certificateIdentities,
),
lifecycleState,
});
}
function normalizeEdgeEndpoint(value) {
let endpoint;
try {
endpoint = new URL(String(value || ""));
} catch {
throw new TypeError("device_edge_channel_endpoint_invalid");
}
if (
endpoint.protocol !== "https:"
|| endpoint.username
|| endpoint.password
|| endpoint.pathname !== "/"
|| endpoint.search
|| endpoint.hash
|| endpoint.port !== ""
|| !isPublicIpv4(endpoint.hostname)
) {
throw new TypeError("device_edge_channel_endpoint_invalid");
}
return endpoint;
}
function isPublicIpv4(value) {
const octets = value.split(".").map(Number);
if (
octets.length !== 4
|| octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) return false;
const [a, b, c] = octets;
if (a < 1 || a >= 224) return false;
if (a === 10 || a === 127) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && (b === 0 || b === 168)) return false;
if (a === 192 && b === 88 && c === 99) return false;
if (a === 198 && (b === 18 || b === 19 || b === 51)) return false;
if (a === 203 && b === 0 && c === 113) return false;
return true;
}
function normalizePattern(value, pattern, code) {
if (typeof value !== "string" || !pattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeDisplayText(value, maxLength, code) {
if (typeof value !== "string") throw new TypeError(code);
const normalized = value.trim();
if (normalized.length < 1 || normalized.length > maxLength) {
throw new TypeError(code);
}
if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) {
throw new TypeError(code);
}
return normalized;
}
function normalizeOptionalTimestamp(value) {
if (value == null || value === "") return null;
if (typeof value !== "string" || !isoTimestampPattern.test(value)) {
throw new TypeError("device_enrollment_expires_at_invalid");
}
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
throw new TypeError("device_enrollment_expires_at_invalid");
}
return value;
}
function normalizeEnum(value, allowed, code) {
if (typeof value !== "string" || !allowed.has(value)) {
throw new TypeError(code);
}
return value;
}
function assertPlainObject(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(code);
}
}
function assertAllowedKeys(input, allowed) {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (!allowedSet.has(key)) {
throw new TypeError(`device_management_command_field_unexpected:${key}`);
}
}
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,964 +0,0 @@
import { randomUUID } from "node:crypto";
import {
assertPlatformCatalogAuthority,
isInfrastructureManagementCommand,
} from "./infrastructure-management.mjs";
import {
assertProjectCapability,
toProjectRef,
} from "./project-management.mjs";
export async function applyInfrastructureManagementCommand(
client,
{ commandKind, actor, command },
) {
if (!isInfrastructureManagementCommand(commandKind)) {
throw new TypeError("device_infrastructure_command_kind_invalid");
}
if (commandKind === "adapter_package.ensure") {
return ensureAdapterPackage(client, actor, command);
}
if (commandKind === "adapter_version.register") {
return registerAdapterVersion(client, actor, command);
}
if (commandKind === "model_profile.register") {
return registerModelProfile(client, actor, command);
}
if (commandKind === "edge.ensure") {
return ensureEdge(client, actor, command);
}
if (commandKind === "route.ensure") {
return ensureRoute(client, actor, command);
}
return ensureEnrollmentIntent(client, actor, command);
}
export async function authorizeInfrastructureManagementReplay(
client,
{ commandKind, actor, command },
) {
if (!isInfrastructureManagementCommand(commandKind)) {
throw new TypeError("device_infrastructure_command_kind_invalid");
}
if (
commandKind === "adapter_package.ensure"
|| commandKind === "adapter_version.register"
|| commandKind === "model_profile.register"
|| commandKind === "edge.ensure"
) {
assertPlatformCatalogAuthority(actor);
return;
}
const capability = commandKind === "route.ensure"
? "route.manage"
: "device.enroll";
await assertCurrentProjectCapability(client, actor, command.projectId, capability);
}
async function ensureAdapterPackage(client, actor, command) {
assertPlatformCatalogAuthority(actor);
const result = await client.query(
`insert into device_adapter_packages (
id,
package_key,
display_name,
publisher_ref,
lifecycle_state,
created_by_ref
) values ($1, $2, $3, $4, $5, $6)
on conflict (package_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_adapter_packages.publisher_ref = excluded.publisher_ref
and (
device_adapter_packages.lifecycle_state = excluded.lifecycle_state
or (
device_adapter_packages.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
returning id, package_key, display_name, publisher_ref, lifecycle_state,
created_at, updated_at, (xmax = 0) as created`,
[
randomUUID(),
command.packageKey,
command.displayName,
command.publisherRef,
command.lifecycleState,
actor.userRef,
],
);
const row = requireMutationRow(
result,
"device_adapter_package_identity_conflict",
);
await addAudit(client, {
eventType: row.created
? "adapter_package.created"
: "adapter_package.updated",
actorRef: actor.userRef,
payload: {
adapterPackageRef: `adapter-package:${row.id}`,
packageKey: row.package_key,
publisherRef: row.publisher_ref,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
adapterPackage: adapterPackageView(row),
};
}
async function registerAdapterVersion(client, actor, command) {
assertPlatformCatalogAuthority(actor);
const adapterPackage = await findAdapterPackage(
client,
command.adapterPackageId,
);
if (adapterPackage.lifecycle_state !== "active") {
throw domainError("device_adapter_package_inactive", 409);
}
const result = await client.query(
`insert into device_adapter_versions (
id,
adapter_package_id,
version,
runtime_package_ref,
content_digest,
contract_version,
capabilities,
lifecycle_state,
registered_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (adapter_package_id, version) do update set
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_adapter_versions.runtime_package_ref = excluded.runtime_package_ref
and device_adapter_versions.content_digest = excluded.content_digest
and device_adapter_versions.contract_version = excluded.contract_version
and device_adapter_versions.capabilities = excluded.capabilities
and (
device_adapter_versions.lifecycle_state = excluded.lifecycle_state
or (
device_adapter_versions.lifecycle_state = 'draft'
and excluded.lifecycle_state in ('active', 'retired')
)
or (
device_adapter_versions.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
returning id, adapter_package_id, version, runtime_package_ref,
content_digest, contract_version, capabilities, lifecycle_state,
created_at, updated_at, (xmax = 0) as created`,
[
randomUUID(),
command.adapterPackageId,
command.version,
command.runtimePackageRef,
command.contentDigest,
command.contractVersion,
command.capabilities,
command.lifecycleState,
actor.userRef,
],
);
const row = requireMutationRow(
result,
"device_adapter_version_identity_conflict",
);
await addAudit(client, {
eventType: row.created
? "adapter_version.registered"
: "adapter_version.lifecycle_updated",
actorRef: actor.userRef,
payload: {
adapterPackageRef: `adapter-package:${row.adapter_package_id}`,
adapterVersionRef: `adapter-version:${row.id}`,
version: row.version,
contentDigest: row.content_digest,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
adapterPackage: adapterPackageView(adapterPackage),
adapterVersion: adapterVersionView(row),
};
}
async function registerModelProfile(client, actor, command) {
assertPlatformCatalogAuthority(actor);
const adapterVersion = await findAdapterVersion(
client,
command.adapterVersionId,
);
if (
adapterVersion.package_lifecycle_state !== "active"
|| adapterVersion.lifecycle_state === "retired"
) {
throw domainError("device_adapter_version_inactive", 409);
}
if (
command.lifecycleState === "active"
&& adapterVersion.lifecycle_state !== "active"
) {
throw domainError("device_model_profile_adapter_not_active", 409);
}
const profile = {
schemaVersion: command.schemaVersion,
profileRef: command.profileRef,
vendor: command.vendor,
model: command.model,
deviceType: command.deviceType,
protocol: command.protocol,
schemaArtifactRef: command.schemaArtifactRef,
capabilities: command.capabilities,
};
const existingProfile = await findOptionalModelProfileRegistration(
client,
command.profileRef,
);
const adoptsLegacyProfile = isLegacyMetadataOnlyProfile(existingProfile);
const result = await client.query(
`insert into device_model_profiles (
profile_ref,
schema_version,
vendor,
model,
device_type,
protocol,
profile,
adapter_version_id,
schema_artifact_ref,
profile_digest,
capabilities,
lifecycle_state
) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12)
on conflict (profile_ref) do update set
adapter_version_id = case
when device_model_profiles.adapter_version_id is null
then excluded.adapter_version_id
else device_model_profiles.adapter_version_id
end,
schema_artifact_ref = case
when device_model_profiles.schema_artifact_ref is null
then excluded.schema_artifact_ref
else device_model_profiles.schema_artifact_ref
end,
profile_digest = case
when device_model_profiles.profile_digest is null
then excluded.profile_digest
else device_model_profiles.profile_digest
end,
capabilities = case
when cardinality(device_model_profiles.capabilities) = 0
then excluded.capabilities
else device_model_profiles.capabilities
end,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_model_profiles.schema_version = excluded.schema_version
and device_model_profiles.vendor = excluded.vendor
and device_model_profiles.model = excluded.model
and device_model_profiles.device_type = excluded.device_type
and device_model_profiles.protocol = excluded.protocol
and (
(
(
device_model_profiles.profile = excluded.profile
or (
jsonb_typeof(device_model_profiles.profile) = 'object'
and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version
and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref
and device_model_profiles.profile ->> 'vendor' = excluded.vendor
and device_model_profiles.profile ->> 'model' = excluded.model
and device_model_profiles.profile ->> 'deviceType' = excluded.device_type
and device_model_profiles.profile ->> 'protocol' = excluded.protocol
)
)
and device_model_profiles.adapter_version_id = excluded.adapter_version_id
and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref
and device_model_profiles.profile_digest = excluded.profile_digest
and device_model_profiles.capabilities = excluded.capabilities
and (
device_model_profiles.lifecycle_state = excluded.lifecycle_state
or (
device_model_profiles.lifecycle_state = 'draft'
and excluded.lifecycle_state in ('active', 'retired')
)
or (
device_model_profiles.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
)
or (
jsonb_typeof(device_model_profiles.profile) = 'object'
and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version
and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref
and device_model_profiles.profile ->> 'vendor' = excluded.vendor
and device_model_profiles.profile ->> 'model' = excluded.model
and device_model_profiles.profile ->> 'deviceType' = excluded.device_type
and device_model_profiles.profile ->> 'protocol' = excluded.protocol
and device_model_profiles.adapter_version_id is null
and device_model_profiles.schema_artifact_ref is null
and device_model_profiles.profile_digest is null
and cardinality(device_model_profiles.capabilities) = 0
and device_model_profiles.lifecycle_state = 'active'
and excluded.lifecycle_state = 'draft'
)
)
returning profile_ref, schema_version, vendor, model, device_type,
protocol, adapter_version_id, schema_artifact_ref, profile_digest,
capabilities, lifecycle_state, created_at, updated_at,
(xmax = 0) as created`,
[
command.profileRef,
command.schemaVersion,
command.vendor,
command.model,
command.deviceType,
command.protocol,
JSON.stringify(profile),
command.adapterVersionId,
command.schemaArtifactRef,
command.profileDigest,
command.capabilities,
command.lifecycleState,
],
);
const row = requireMutationRow(
result,
"device_model_profile_identity_conflict",
);
await addAudit(client, {
eventType: row.created
? "model_profile.registered"
: adoptsLegacyProfile
? "model_profile.registry_adopted"
: "model_profile.lifecycle_updated",
actorRef: actor.userRef,
payload: {
adapterVersionRef: `adapter-version:${row.adapter_version_id}`,
modelProfileRef: row.profile_ref,
profileDigest: row.profile_digest,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
adapterVersion: adapterVersionView(adapterVersion),
modelProfile: modelProfileView(row),
};
}
async function findOptionalModelProfileRegistration(client, profileRef) {
const result = await client.query(
`select profile_ref, adapter_version_id, schema_artifact_ref,
profile_digest, capabilities, lifecycle_state
from device_model_profiles
where profile_ref = $1
for update`,
[profileRef],
);
return result.rows[0] ?? null;
}
function isLegacyMetadataOnlyProfile(profile) {
return profile != null
&& profile.adapter_version_id == null
&& profile.schema_artifact_ref == null
&& profile.profile_digest == null
&& Array.isArray(profile.capabilities)
&& profile.capabilities.length === 0
&& profile.lifecycle_state === "active";
}
async function ensureEdge(client, actor, command) {
assertPlatformCatalogAuthority(actor);
const channelProvided = command.channel !== undefined;
const channel = command.channel ?? {
endpoint: null,
servername: null,
generationRef: null,
trustBundleRef: null,
certificateIdentities: [],
lifecycleState: "disabled",
};
const result = await client.query(
`insert into device_edges (
id,
edge_key,
display_name,
deployment_ref,
lifecycle_state,
channel_endpoint,
channel_servername,
channel_generation_ref,
channel_trust_bundle_ref,
channel_certificate_identities,
channel_lifecycle_state,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $13)
on conflict (edge_key) do update set
display_name = excluded.display_name,
deployment_ref = excluded.deployment_ref,
lifecycle_state = excluded.lifecycle_state,
channel_endpoint = case when $12 then excluded.channel_endpoint
else device_edges.channel_endpoint end,
channel_servername = case when $12 then excluded.channel_servername
else device_edges.channel_servername end,
channel_generation_ref = case when $12 then excluded.channel_generation_ref
else device_edges.channel_generation_ref end,
channel_trust_bundle_ref = case when $12 then excluded.channel_trust_bundle_ref
else device_edges.channel_trust_bundle_ref end,
channel_certificate_identities = case when $12
then excluded.channel_certificate_identities
else device_edges.channel_certificate_identities end,
channel_lifecycle_state = case when $12
then excluded.channel_lifecycle_state
else device_edges.channel_lifecycle_state end,
updated_at = now()
where (
device_edges.lifecycle_state = excluded.lifecycle_state
or (
device_edges.lifecycle_state = 'provisioning'
and excluded.lifecycle_state in ('active', 'retired')
)
or (
device_edges.lifecycle_state = 'active'
and excluded.lifecycle_state in ('suspended', 'retired')
)
or (
device_edges.lifecycle_state = 'suspended'
and excluded.lifecycle_state in ('active', 'retired')
)
)
and (
not $12
or device_edges.channel_lifecycle_state = excluded.channel_lifecycle_state
or (
device_edges.channel_lifecycle_state = 'disabled'
and excluded.channel_lifecycle_state = 'active'
)
or (
device_edges.channel_lifecycle_state = 'active'
and excluded.channel_lifecycle_state in ('disabled', 'revoked')
)
)
returning id, edge_key, display_name, deployment_ref, lifecycle_state,
channel_endpoint, channel_servername, channel_generation_ref,
channel_trust_bundle_ref, channel_certificate_identities,
channel_lifecycle_state, created_at, updated_at,
(xmax = 0) as created`,
[
randomUUID(),
command.edgeKey,
command.displayName,
command.deploymentRef,
command.lifecycleState,
channel.endpoint,
channel.servername,
channel.generationRef,
channel.trustBundleRef,
JSON.stringify(channel.certificateIdentities),
channel.lifecycleState,
channelProvided,
actor.userRef,
],
);
const row = requireMutationRow(result, "device_edge_identity_conflict");
await addAudit(client, {
eventType: row.created ? "edge.created" : "edge.updated",
actorRef: actor.userRef,
payload: {
edgeRef: `edge:${row.id}`,
edgeKey: row.edge_key,
deploymentRef: row.deployment_ref,
lifecycleState: row.lifecycle_state,
channelLifecycleState: row.channel_lifecycle_state,
channelGenerationRef: row.channel_generation_ref,
channelTrustBundleRef: row.channel_trust_bundle_ref,
},
});
return {
created: row.created === true,
edge: edgeView(row),
};
}
async function ensureRoute(client, actor, command) {
await assertCurrentProjectCapability(
client,
actor,
command.projectId,
"route.manage",
);
const edge = await findEdge(client, command.edgeId);
const profile = await findModelProfile(client, command.modelProfileRef);
assertRouteDependencies(command, edge, profile);
const result = await client.query(
`insert into device_routes (
id,
project_id,
route_key,
display_name,
edge_id,
model_profile_ref,
listener_ref,
protocol,
direction,
lifecycle_state,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (project_id, route_key) do update set
display_name = excluded.display_name,
edge_id = excluded.edge_id,
model_profile_ref = excluded.model_profile_ref,
listener_ref = excluded.listener_ref,
protocol = excluded.protocol,
direction = excluded.direction,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where
device_routes.lifecycle_state = excluded.lifecycle_state
or (
device_routes.lifecycle_state = 'draft'
and excluded.lifecycle_state in ('active', 'retired')
)
or (
device_routes.lifecycle_state = 'active'
and excluded.lifecycle_state in ('suspended', 'retired')
)
or (
device_routes.lifecycle_state = 'suspended'
and excluded.lifecycle_state in ('active', 'retired')
)
returning id, project_id, route_key, display_name, edge_id,
model_profile_ref, listener_ref, protocol, direction, lifecycle_state,
created_at, updated_at, (xmax = 0) as created`,
[
randomUUID(),
command.projectId,
command.routeKey,
command.displayName,
command.edgeId,
command.modelProfileRef,
command.listenerRef,
command.protocol,
command.direction,
command.lifecycleState,
actor.userRef,
],
);
const row = requireMutationRow(result, "device_route_identity_conflict");
await addAudit(client, {
eventType: row.created ? "route.created" : "route.updated",
actorRef: actor.userRef,
projectId: command.projectId,
payload: {
projectRef: toProjectRef(command.projectId),
routeRef: `route:${row.id}`,
routeKey: row.route_key,
edgeRef: `edge:${row.edge_id}`,
modelProfileRef: row.model_profile_ref,
listenerRef: row.listener_ref,
direction: row.direction,
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
route: routeView(row),
};
}
async function ensureEnrollmentIntent(client, actor, command) {
await assertCurrentProjectCapability(
client,
actor,
command.projectId,
"device.enroll",
);
const route = await findProjectRoute(
client,
command.projectId,
command.routeId,
);
if (route.lifecycle_state !== "active") {
throw domainError("device_enrollment_route_inactive", 409);
}
if (route.model_profile_ref !== command.modelProfileRef) {
throw domainError("device_enrollment_profile_mismatch", 409);
}
const result = await client.query(
`insert into device_enrollment_intents (
id,
project_id,
enrollment_key,
route_id,
model_profile_ref,
display_name,
expected_identifier_kind,
expected_identifier_digest,
expected_identifier_masked,
expires_at,
created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (project_id, enrollment_key) do update set
display_name = excluded.display_name,
expires_at = excluded.expires_at,
updated_at = now()
where device_enrollment_intents.route_id = excluded.route_id
and device_enrollment_intents.model_profile_ref = excluded.model_profile_ref
and device_enrollment_intents.expected_identifier_kind = excluded.expected_identifier_kind
and device_enrollment_intents.expected_identifier_digest = excluded.expected_identifier_digest
and device_enrollment_intents.expected_identifier_masked = excluded.expected_identifier_masked
and device_enrollment_intents.lifecycle_state = 'pending'
returning id, project_id, enrollment_key, route_id, model_profile_ref,
display_name, expected_identifier_kind, expected_identifier_masked,
lifecycle_state, expires_at, claimed_device_id, created_at, updated_at,
(xmax = 0) as created`,
[
randomUUID(),
command.projectId,
command.enrollmentKey,
command.routeId,
command.modelProfileRef,
command.displayName,
command.identifierKind,
command.identifierDigest,
command.identifierMasked,
command.expiresAt,
actor.userRef,
],
);
const row = requireMutationRow(
result,
"device_enrollment_intent_identity_conflict",
);
await addAudit(client, {
eventType: row.created
? "enrollment_intent.created"
: "enrollment_intent.updated",
actorRef: actor.userRef,
projectId: command.projectId,
payload: {
projectRef: toProjectRef(command.projectId),
enrollmentIntentRef: `enrollment-intent:${row.id}`,
enrollmentKey: row.enrollment_key,
routeRef: `route:${row.route_id}`,
modelProfileRef: row.model_profile_ref,
identifier: {
kind: row.expected_identifier_kind,
masked: row.expected_identifier_masked,
},
lifecycleState: row.lifecycle_state,
},
});
return {
created: row.created === true,
enrollmentIntent: enrollmentIntentView(row),
};
}
async function assertCurrentProjectCapability(
client,
actor,
projectId,
capability,
) {
const project = await client.query(
`select p.id, p.lifecycle_state, os.lifecycle_state as owner_lifecycle_state
from device_projects p
join device_owner_scopes os on os.id = p.owner_scope_id
where p.id = $1
for share of p, os`,
[projectId],
);
const row = project.rows[0];
if (!row) throw domainError("device_project_not_found", 404);
if (row.owner_lifecycle_state !== "active") {
throw domainError("device_owner_scope_inactive", 409);
}
if (row.lifecycle_state !== "active") {
throw domainError("device_project_inactive", 409);
}
const grants = await client.query(
`select id, principal_kind, principal_ref, project_role,
capability_allow, capability_deny, lifecycle_state
from device_project_grants
where project_id = $1
order by created_at, id
for share`,
[projectId],
);
assertProjectCapability(
actor,
grants.rows.map((grant) => ({
grantRef: `grant:${grant.id}`,
principalKind: grant.principal_kind,
principalRef: grant.principal_ref,
projectRole: grant.project_role,
capabilityAllow: grant.capability_allow ?? [],
capabilityDeny: grant.capability_deny ?? [],
lifecycleState: grant.lifecycle_state,
})),
capability,
);
}
async function findAdapterPackage(client, adapterPackageId) {
const result = await client.query(
`select id, package_key, display_name, publisher_ref, lifecycle_state,
created_at, updated_at
from device_adapter_packages
where id = $1
for share`,
[adapterPackageId],
);
if (!result.rows[0]) {
throw domainError("device_adapter_package_not_found", 404);
}
return result.rows[0];
}
async function findAdapterVersion(client, adapterVersionId) {
const result = await client.query(
`select av.id, av.adapter_package_id, av.version,
av.runtime_package_ref, av.content_digest, av.contract_version,
av.capabilities, av.lifecycle_state, av.created_at, av.updated_at,
ap.lifecycle_state as package_lifecycle_state
from device_adapter_versions av
join device_adapter_packages ap on ap.id = av.adapter_package_id
where av.id = $1
for share of av, ap`,
[adapterVersionId],
);
if (!result.rows[0]) {
throw domainError("device_adapter_version_not_found", 404);
}
return result.rows[0];
}
async function findEdge(client, edgeId) {
const result = await client.query(
`select id, edge_key, display_name, deployment_ref, lifecycle_state,
created_at, updated_at
from device_edges
where id = $1
for share`,
[edgeId],
);
if (!result.rows[0]) throw domainError("device_edge_not_found", 404);
return result.rows[0];
}
async function findModelProfile(client, profileRef) {
const result = await client.query(
`select mp.profile_ref, mp.schema_version, mp.vendor, mp.model,
mp.device_type, mp.protocol, mp.adapter_version_id,
mp.schema_artifact_ref, mp.profile_digest, mp.capabilities,
mp.lifecycle_state, mp.created_at, mp.updated_at,
av.lifecycle_state as adapter_lifecycle_state,
ap.lifecycle_state as package_lifecycle_state
from device_model_profiles mp
left join device_adapter_versions av on av.id = mp.adapter_version_id
left join device_adapter_packages ap on ap.id = av.adapter_package_id
where mp.profile_ref = $1
for share of mp`,
[profileRef],
);
if (!result.rows[0]) {
throw domainError("device_model_profile_not_found", 404);
}
return result.rows[0];
}
async function findProjectRoute(client, projectId, routeId) {
const result = await client.query(
`select id, project_id, route_key, display_name, edge_id,
model_profile_ref, listener_ref, protocol, direction, lifecycle_state,
created_at, updated_at
from device_routes
where id = $1 and project_id = $2
for share`,
[routeId, projectId],
);
if (!result.rows[0]) throw domainError("device_route_not_found", 404);
return result.rows[0];
}
function assertRouteDependencies(command, edge, profile) {
if (profile.adapter_version_id == null) {
throw domainError("device_model_profile_unregistered", 409);
}
if (profile.protocol !== command.protocol) {
throw domainError("device_route_protocol_mismatch", 409);
}
if (
edge.lifecycle_state === "retired"
|| profile.lifecycle_state === "retired"
|| profile.adapter_lifecycle_state === "retired"
|| profile.package_lifecycle_state === "retired"
) {
throw domainError("device_route_dependency_inactive", 409);
}
if (
command.lifecycleState === "active"
&& (
edge.lifecycle_state !== "active"
|| profile.lifecycle_state !== "active"
|| profile.adapter_lifecycle_state !== "active"
|| profile.package_lifecycle_state !== "active"
)
) {
throw domainError("device_route_dependency_not_active", 409);
}
}
async function addAudit(client, {
eventType,
actorRef,
projectId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id,
event_type,
actor_ref,
project_id,
payload
) values ($1, $2, $3, $4, $5::jsonb)`,
[randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)],
);
}
function requireMutationRow(result, code) {
if (!result.rows[0]) throw domainError(code, 409);
return result.rows[0];
}
function adapterPackageView(row) {
return {
adapterPackageRef: `adapter-package:${row.id}`,
packageKey: row.package_key,
displayName: row.display_name,
publisherRef: row.publisher_ref,
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function adapterVersionView(row) {
return {
adapterVersionRef: `adapter-version:${row.id}`,
adapterPackageRef: `adapter-package:${row.adapter_package_id}`,
version: row.version,
runtimePackageRef: row.runtime_package_ref,
contentDigest: row.content_digest,
contractVersion: row.contract_version,
capabilities: [...(row.capabilities ?? [])].sort(),
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function modelProfileView(row) {
return {
modelProfileRef: row.profile_ref,
adapterVersionRef: `adapter-version:${row.adapter_version_id}`,
schemaVersion: row.schema_version,
vendor: row.vendor,
model: row.model,
deviceType: row.device_type,
protocol: row.protocol,
schemaArtifactRef: row.schema_artifact_ref,
profileDigest: row.profile_digest,
capabilities: [...(row.capabilities ?? [])].sort(),
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function edgeView(row) {
return {
edgeRef: `edge:${row.id}`,
edgeKey: row.edge_key,
displayName: row.display_name,
deploymentRef: row.deployment_ref ?? null,
lifecycleState: row.lifecycle_state,
channel: {
lifecycleState: row.channel_lifecycle_state ?? "disabled",
endpoint: row.channel_endpoint ?? null,
servername: row.channel_servername ?? null,
generationRef: row.channel_generation_ref ?? null,
trustBundleRef: row.channel_trust_bundle_ref ?? null,
certificateIdentities: [...(row.channel_certificate_identities ?? [])],
},
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function routeView(row) {
return {
routeRef: `route:${row.id}`,
projectRef: toProjectRef(row.project_id),
routeKey: row.route_key,
displayName: row.display_name,
edgeRef: `edge:${row.edge_id}`,
modelProfileRef: row.model_profile_ref,
listenerRef: row.listener_ref,
protocol: row.protocol,
direction: row.direction,
lifecycleState: row.lifecycle_state,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function enrollmentIntentView(row) {
return {
enrollmentIntentRef: `enrollment-intent:${row.id}`,
projectRef: toProjectRef(row.project_id),
enrollmentKey: row.enrollment_key,
routeRef: `route:${row.route_id}`,
modelProfileRef: row.model_profile_ref,
displayName: row.display_name,
identifier: {
kind: row.expected_identifier_kind,
masked: row.expected_identifier_masked,
},
lifecycleState: row.lifecycle_state,
expiresAt: toIso(row.expires_at),
claimedDeviceRef: row.claimed_device_id
? `device:${row.claimed_device_id}`
: null,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -1,139 +0,0 @@
export const DEVICE_LIFECYCLE_COMMAND_KINDS = Object.freeze([
"device.claim",
"device.transfer",
"discovery.reject",
"discovery.expire",
]);
const commandKindSet = new Set(DEVICE_LIFECYCLE_COMMAND_KINDS);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
export function isLifecycleManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeLifecycleManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_lifecycle_command_kind_invalid");
}
assertPlainObject(input);
if (kind === "device.claim") {
assertAllowedKeys(input, [
"projectRef",
"enrollmentIntentRef",
"discoveryRef",
"deviceKey",
"displayName",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
enrollmentIntentId: normalizeEntityRef(
input.enrollmentIntentRef,
"enrollment-intent",
),
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
deviceKey: normalizePattern(
input.deviceKey,
keyPattern,
"device_key_invalid",
),
displayName: normalizeDisplayText(input.displayName, 160),
});
}
if (kind === "device.transfer") {
assertAllowedKeys(input, [
"deviceRef",
"sourceProjectRef",
"targetProjectRef",
"targetDeviceKey",
]);
const sourceProjectId = normalizeEntityRef(
input.sourceProjectRef,
"project",
);
const targetProjectId = normalizeEntityRef(
input.targetProjectRef,
"project",
);
if (sourceProjectId === targetProjectId) {
throw new TypeError("device_transfer_target_same_as_source");
}
return Object.freeze({
deviceId: normalizeEntityRef(input.deviceRef, "device"),
sourceProjectId,
targetProjectId,
targetDeviceKey: normalizePattern(
input.targetDeviceKey,
keyPattern,
"device_transfer_target_key_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef",
"discoveryRef",
"resolutionCode",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
resolutionCode: normalizePattern(
input.resolutionCode,
resolutionPattern,
"device_discovery_resolution_code_invalid",
),
});
}
function normalizeEntityRef(value, prefix) {
if (typeof value !== "string") {
throw new TypeError(`device_${prefix}_ref_invalid`);
}
const match = value.match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizePattern(value, pattern, code) {
if (typeof value !== "string" || !pattern.test(value)) {
throw new TypeError(code);
}
return value;
}
function normalizeDisplayText(value, maxLength) {
if (typeof value !== "string") {
throw new TypeError("device_display_name_invalid");
}
const normalized = value.trim();
if (
normalized.length < 1
|| normalized.length > maxLength
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
) {
throw new TypeError("device_display_name_invalid");
}
return normalized;
}
function assertPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("device_lifecycle_command_invalid");
}
}
function assertAllowedKeys(input, allowed) {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (!allowedSet.has(key)) {
throw new TypeError(`device_management_command_field_unexpected:${key}`);
}
}
}

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