refactor: move Device Core source to standalone repository
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
**/*.prev-*
|
||||
**/*.next-*
|
||||
runtime
|
||||
secrets
|
||||
@@ -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 0–1, 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 для целевых файлов.
|
||||
@@ -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 |
|
||||
Generated
-278
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
-123
@@ -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;
|
||||
-68
@@ -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;
|
||||
-208
@@ -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;
|
||||
-326
@@ -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;
|
||||
-25
@@ -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;
|
||||
-233
@@ -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;
|
||||
-27
@@ -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;
|
||||
-610
@@ -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;
|
||||
-31
@@ -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;
|
||||
-66
@@ -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
Reference in New Issue
Block a user