fix(device-core): bind edge telemetry to enrolled route

This commit is contained in:
Codex
2026-08-13 09:41:14 +03:00
parent fe1c8054be
commit 71fff82c99
7 changed files with 309 additions and 10 deletions
@@ -108,8 +108,14 @@ export function createDeviceEdgeChannelSupervisor(options = {}) {
ca, ca,
}, },
coreIdentity: config.coreIdentity.identityRef, coreIdentity: config.coreIdentity.identityRef,
observeDiscovery: config.gatewayIngest.observeDiscovery, observeDiscovery: (signal) => config.gatewayIngest.observeDiscovery(
acceptMessage: config.gatewayIngest.acceptMessage, signal,
{ authenticatedEdgeRef: registration.edgeRegistrationId },
),
acceptMessage: (message) => config.gatewayIngest.acceptMessage(
message,
{ authenticatedEdgeRef: registration.edgeRegistrationId },
),
commandTransport: config.typedCommandRuntime commandTransport: config.typedCommandRuntime
? "typed-service-ping-v1" ? "typed-service-ping-v1"
: "disabled", : "disabled",
@@ -22,12 +22,26 @@ export function createDeviceGatewayIngest({ repository, identifierPepper } = {})
} }
return Object.freeze({ return Object.freeze({
async observeDiscovery(input) { async observeDiscovery(input, context = {}) {
const signal = normalizeDiscoverySignal(input); const receivedSignal = normalizeDiscoverySignal(input);
const identifierDigest = hashRestrictedIdentifier( const identifierDigest = hashRestrictedIdentifier(
signal.identifier, receivedSignal.identifier,
identifierPepper, 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 safeView = assertSafeProjection(toSafeDiscoveryView(signal));
const discovery = await repository.upsertQuarantineDiscovery({ const discovery = await repository.upsertQuarantineDiscovery({
identifierDigest, identifierDigest,
@@ -42,12 +56,27 @@ export function createDeviceGatewayIngest({ repository, identifierPepper } = {})
}); });
}, },
async acceptMessage(input) { async acceptMessage(input, context = {}) {
const message = normalizeAdapterMessage(input); const receivedMessage = normalizeAdapterMessage(input);
const identifierDigest = hashRestrictedIdentifier( const identifierDigest = hashRestrictedIdentifier(
message.identifier, receivedMessage.identifier,
identifierPepper, 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 safeView = assertSafeProjection(toSafeAdapterMessageView(message));
const requestDigest = gatewayMessageRequestDigest({ const requestDigest = gatewayMessageRequestDigest({
edgeRef: safeView.edgeRef, edgeRef: safeView.edgeRef,
@@ -74,6 +103,21 @@ export function createDeviceGatewayIngest({ repository, identifierPepper } = {})
}); });
} }
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) { function gatewayMessageRequestDigest(value) {
return `sha256:${createHash("sha256") return `sha256:${createHash("sha256")
.update(JSON.stringify(value), "utf8") .update(JSON.stringify(value), "utf8")
@@ -0,0 +1,111 @@
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;
}
@@ -7,6 +7,7 @@ import pg from "pg";
import { observeQuarantineDiscovery } from "./discovery-repository.mjs"; import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
import { acceptGatewayMessage } from "./gateway-message-repository.mjs"; import { acceptGatewayMessage } from "./gateway-message-repository.mjs";
import { resolveInboundRoute } from "./inbound-route-repository.mjs";
import { import {
applyControlResourceManagementCommand, applyControlResourceManagementCommand,
authorizeControlResourceManagementReplay, authorizeControlResourceManagementReplay,
@@ -116,6 +117,10 @@ export class PostgresDeviceRepository {
}); });
} }
async resolveInboundRoute(input) {
return this.#executeRead((client) => resolveInboundRoute(client, input));
}
async executeManagementCommand({ async executeManagementCommand({
idempotencyKey, idempotencyKey,
commandKind, commandKind,
@@ -8,13 +8,14 @@ import {
test("supervisor reconciles one in-process client per active Edge", async () => { test("supervisor reconciles one in-process client per active Edge", async () => {
let registrations = [registration("channel-generation:1")]; let registrations = [registration("channel-generation:1")];
const clients = []; const clients = [];
const ingestCalls = [];
const supervisor = createDeviceEdgeChannelSupervisor({ const supervisor = createDeviceEdgeChannelSupervisor({
repository: { repository: {
listActiveEdgeChannelRegistrations: async () => registrations, listActiveEdgeChannelRegistrations: async () => registrations,
}, },
gatewayIngest: { gatewayIngest: {
observeDiscovery: async () => ({}), observeDiscovery: async (...args) => ingestCalls.push(["discovery", ...args]),
acceptMessage: async () => ({}), acceptMessage: async (...args) => ingestCalls.push(["message", ...args]),
}, },
coreIdentity: { coreIdentity: {
identityRef: "workload:device-control-core", identityRef: "workload:device-control-core",
@@ -38,6 +39,20 @@ test("supervisor reconciles one in-process client per active Edge", async () =>
assert.equal(clients.length, 1); assert.equal(clients.length, 1);
assert.equal(supervisor.status().accepted, 1); assert.equal(supervisor.status().accepted, 1);
assert.equal(clients[0].options.registration.channelGeneration, "channel-generation:1"); assert.equal(clients[0].options.registration.channelGeneration, "channel-generation:1");
await clients[0].options.observeDiscovery({ signal: true });
await clients[0].options.acceptMessage({ message: true });
assert.deepEqual(ingestCalls, [
[
"discovery",
{ signal: true },
{ authenticatedEdgeRef: "edge:pilot" },
],
[
"message",
{ message: true },
{ authenticatedEdgeRef: "edge:pilot" },
],
]);
await supervisor.reconcile(); await supervisor.reconcile();
assert.equal(clients.length, 1); assert.equal(clients.length, 1);
@@ -53,6 +53,55 @@ test("shared gateway ingest masks identifiers for HTTP and Edge callers", async
assert.equal(JSON.stringify(stored).includes(rawImei), false); assert.equal(JSON.stringify(stored).includes(rawImei), false);
}); });
test("authenticated Edge identity resolves the allowlisted project route", async () => {
const stored = [];
const resolutions = [];
const authenticatedEdgeRef = "edge:11111111-1111-4111-8111-111111111111";
const routeRef = "route:22222222-2222-4222-8222-222222222222";
const ingest = createDeviceGatewayIngest({
identifierPepper,
repository: {
async resolveInboundRoute(value) {
resolutions.push(value);
return routeRef;
},
async upsertQuarantineDiscovery(value) {
stored.push(value);
return {
created: false,
value: { ...value.safeView, discoveryRef: "discovery:test" },
};
},
async acceptAdapterMessage(value) {
stored.push(value);
return {
acceptance: {
schemaVersion: "nodedc.device-adapter-acceptance.v1",
acceptanceRef: "acceptance:test",
idempotencyKey: value.safeView.idempotencyKey,
status: "accepted",
replayed: false,
acceptedAt: "2026-08-11T12:00:00.000Z",
},
claimedDeviceRef: null,
};
},
},
});
await ingest.observeDiscovery(discoverySignal(), { authenticatedEdgeRef });
await ingest.acceptMessage(adapterMessage(), { authenticatedEdgeRef });
assert.equal(resolutions.length, 2);
assert.equal(resolutions[0].edgeRef, authenticatedEdgeRef);
assert.match(resolutions[0].identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
assert.equal(stored[0].safeView.routeRef, routeRef);
assert.equal(stored[1].safeView.routeRef, routeRef);
assert.equal(stored[1].safeView.edgeRef, authenticatedEdgeRef);
assert.notEqual(stored[1].safeView.edgeRef, adapterMessage().edgeRef);
assert.equal(JSON.stringify(resolutions).includes(rawImei), false);
});
function discoverySignal() { function discoverySignal() {
return { return {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
resolveInboundRoute,
} from "../src/inbound-route-repository.mjs";
const edgeRef = "edge:11111111-1111-4111-8111-111111111111";
const routeId = "22222222-2222-4222-8222-222222222222";
const identifierDigest = `hmac-sha256:${"a".repeat(64)}`;
test("inbound route resolution is scoped by authenticated Edge and enrollment", async () => {
const queries = [];
const client = {
async query(sql, values) {
queries.push({ sql, values });
return { rows: [{ id: routeId }] };
},
};
const result = await resolveInboundRoute(client, input());
assert.equal(result, `route:${routeId}`);
assert.equal(queries.length, 1);
assert.match(queries[0].sql, /device_enrollment_intents/);
assert.match(queries[0].sql, /r\.edge_id = \$1/);
assert.match(queries[0].sql, /ei\.expected_identifier_digest = \$5/);
assert.deepEqual(queries[0].values, [
edgeRef.slice("edge:".length),
"arusnavi.b2.internal.v1",
"INTERNAL",
"imei",
identifierDigest,
"2026-08-13T09:00:00.000Z",
]);
});
test("inbound route resolution leaves unknown identifiers quarantined", async () => {
const result = await resolveInboundRoute(
{ query: async () => ({ rows: [] }) },
input(),
);
assert.equal(result, null);
});
test("inbound route resolution fails closed on ambiguous ownership", async () => {
await assert.rejects(
() => resolveInboundRoute(
{ query: async () => ({ rows: [{ id: routeId }, { id: routeId }] }) },
input(),
),
(error) => {
assert.equal(error.message, "device_inbound_route_ambiguous");
assert.equal(error.statusCode, 409);
return true;
},
);
});
function input() {
return {
edgeRef,
modelProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
identifierKind: "imei",
identifierDigest,
observedAt: "2026-08-13T09:00:00.000Z",
};
}