feat(data-plane): add provider contracts and ontology delivery
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=18105
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/package.json /app/package-lock.json ./
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 18105
|
||||
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -0,0 +1,70 @@
|
||||
# NODE.DC Gelios Gateway
|
||||
|
||||
Storage and read-model service for the Gelios provider domain. It has no
|
||||
provider credential, no provider HTTP client, no scheduler and no command
|
||||
transport.
|
||||
|
||||
## Boundary
|
||||
|
||||
The protected Engine L2 Gelios Collector is the only component that reaches
|
||||
the provider and uses the Engine Credential. It submits authenticated unit
|
||||
intake to this Gateway. The Gateway enforces the connection scope and approved
|
||||
unit allowlist, persists current state/history, and exposes scoped read/SSE
|
||||
contracts to other Engine workflows and map consumers.
|
||||
|
||||
Neither a provider token nor a credential reference is accepted, stored or
|
||||
returned by this service.
|
||||
|
||||
## Runtime contracts
|
||||
|
||||
`GET /healthz` is unprotected and contains no secret material.
|
||||
|
||||
All `/internal/gelios/v1/*` routes require the internal service credential:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <NODEDC_INTERNAL_ACCESS_TOKEN>
|
||||
X-NODEDC-Tenant-Id: <approved-tenant-id>
|
||||
X-NODEDC-Connection-Id: <approved-connection-id>
|
||||
```
|
||||
|
||||
```text
|
||||
POST /internal/gelios/v1/intake/units
|
||||
GET /internal/gelios/v1/status
|
||||
GET /internal/gelios/v1/capabilities
|
||||
GET /internal/gelios/v1/units/current
|
||||
GET /internal/gelios/v1/stream
|
||||
GET /internal/gelios/v1/data-products/fleet.positions.current.v1/snapshot
|
||||
GET /internal/gelios/v1/data-products/fleet.positions.current.v1/stream
|
||||
```
|
||||
|
||||
`intake/units` is disabled by default. It accepts a bounded payload with a
|
||||
`units` array or an equivalent safe unit-list envelope; it does not accept a
|
||||
URL, method, token or command. The command catalog remains visible only as
|
||||
disabled ontology metadata.
|
||||
|
||||
The connection policy is explicit: `GELIOS_UNIT_SCOPE=allowlist` uses fixed
|
||||
`GELIOS_ALLOWED_UNIT_IDS`; `GELIOS_UNIT_SCOPE=all` accepts every unit returned
|
||||
by that already-approved tenant + connection, including units added later by
|
||||
the provider. Missing units are never deleted from storage: their stable
|
||||
provider IDs and last-seen history remain available to the presentation layer.
|
||||
|
||||
`fleet.positions.current.v1` is the first provider-neutral product. Its
|
||||
snapshot and SSE patch stream contain semantic moving objects, position,
|
||||
motion, quality and `active` / `stale` / `no-position` state. They deliberately
|
||||
exclude raw telemetry and provider transport payloads. A consumer first fetches
|
||||
the snapshot and then attaches the stream; the stream `ready` event never
|
||||
pretends to be a complete current-state snapshot.
|
||||
|
||||
## Storage
|
||||
|
||||
The database requires TimescaleDB and PostGIS. It keeps a current projection,
|
||||
append-only time-series snapshots, bounded restricted intake envelopes and
|
||||
collection audit. Raw envelopes have no browser/API route and expire according
|
||||
to `GELIOS_RAW_RETENTION_DAYS`.
|
||||
|
||||
## Local checks
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run smoke
|
||||
```
|
||||
+1008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/gelios-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"smoke": "node src/scripts/smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const GELIOS_CAPABILITIES = Object.freeze([
|
||||
{
|
||||
id: "gelios.units.current_intake",
|
||||
class: "internal-safe-intake",
|
||||
enabled: true,
|
||||
summary: "Authenticated normalized current-unit intake from the protected Engine Collector.",
|
||||
},
|
||||
{
|
||||
id: "fleet.positions.current.v1",
|
||||
class: "internal-data-product",
|
||||
enabled: true,
|
||||
summary: "Provider-neutral current fleet positions snapshot and patch stream; no raw telemetry fields.",
|
||||
},
|
||||
{
|
||||
id: "gelios.commands.catalog",
|
||||
class: "red-read-catalogue",
|
||||
enabled: false,
|
||||
summary: "Catalogued for ontology only; not connected to the collector or Gateway.",
|
||||
},
|
||||
{
|
||||
id: "gelios.commands.send",
|
||||
class: "red-write",
|
||||
enabled: false,
|
||||
summary: "No transport is implemented.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,80 @@
|
||||
export function readConfig(env = process.env) {
|
||||
const rawRetentionDays = integer(env.GELIOS_RAW_RETENTION_DAYS, 14, 1, 3650);
|
||||
|
||||
return {
|
||||
nodeEnv: string(env.NODE_ENV, "development"),
|
||||
port: integer(env.PORT, 18105, 1, 65535),
|
||||
databaseUrl: required(env.DATABASE_URL, "DATABASE_URL"),
|
||||
databasePoolSize: integer(env.GELIOS_DATABASE_POOL_SIZE, 10, 1, 50),
|
||||
internalAccessToken: optional(env.NODEDC_INTERNAL_ACCESS_TOKEN),
|
||||
tenantId: slug(required(env.GELIOS_TENANT_ID, "GELIOS_TENANT_ID"), "GELIOS_TENANT_ID"),
|
||||
connectionId: slug(required(env.GELIOS_CONNECTION_ID, "GELIOS_CONNECTION_ID"), "GELIOS_CONNECTION_ID"),
|
||||
unitScope: collectionScope(env.GELIOS_UNIT_SCOPE),
|
||||
allowedUnitIds: intList(env.GELIOS_ALLOWED_UNIT_IDS),
|
||||
intakeEnabled: boolean(env.GELIOS_INTAKE_ENABLED, false),
|
||||
rawRetentionDays,
|
||||
positionStaleAfterMs: integer(env.GELIOS_POSITION_STALE_AFTER_MS, 300_000, 1_000, 86_400_000),
|
||||
};
|
||||
}
|
||||
|
||||
function collectionScope(value) {
|
||||
const candidate = optional(value).toLowerCase() || "allowlist";
|
||||
if (["allowlist", "all"].includes(candidate)) return candidate;
|
||||
throw new Error("GELIOS_UNIT_SCOPE_must_be_allowlist_or_all");
|
||||
}
|
||||
|
||||
export function unitIsInScope(config, sourceUnitId) {
|
||||
return config?.unitScope === "all" || config?.allowedUnitIds?.includes(sourceUnitId) === true;
|
||||
}
|
||||
|
||||
function required(value, name) {
|
||||
const normalized = optional(value);
|
||||
if (!normalized) throw new Error(`${name}_required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optional(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized || "";
|
||||
}
|
||||
|
||||
function string(value, fallback) {
|
||||
return optional(value) || fallback;
|
||||
}
|
||||
|
||||
function integer(value, fallback, min, max) {
|
||||
const candidate = optional(value);
|
||||
if (!candidate) return fallback;
|
||||
const number = Number.parseInt(candidate, 10);
|
||||
if (!Number.isInteger(number) || number < min || number > max) {
|
||||
throw new Error(`invalid_integer:${candidate}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function boolean(value, fallback) {
|
||||
const candidate = optional(value).toLowerCase();
|
||||
if (!candidate) return fallback;
|
||||
if (["1", "true", "yes", "on"].includes(candidate)) return true;
|
||||
if (["0", "false", "no", "off"].includes(candidate)) return false;
|
||||
throw new Error(`invalid_boolean:${candidate}`);
|
||||
}
|
||||
|
||||
function intList(value) {
|
||||
const values = optional(value)
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => Number.parseInt(item, 10));
|
||||
if (values.some((item) => !Number.isInteger(item) || item <= 0)) {
|
||||
throw new Error("GELIOS_ALLOWED_UNIT_IDS_must_contain_positive_integers");
|
||||
}
|
||||
return [...new Set(values)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function slug(value, name) {
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,95}$/i.test(value)) {
|
||||
throw new Error(`${name}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const FLEET_POSITIONS_CURRENT = Object.freeze({
|
||||
id: "fleet.positions.current.v1",
|
||||
version: "1.0.0",
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: Object.freeze(["map.moving_object", "geo.position"]),
|
||||
});
|
||||
|
||||
export function toFleetPositionsSnapshot(rows, { tenantId, connectionId, generatedAt = new Date(), staleAfterMs }) {
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.snapshot.v1",
|
||||
dataProduct: FLEET_POSITIONS_CURRENT,
|
||||
scope: { tenantId, connectionId },
|
||||
generatedAt: iso(generatedAt),
|
||||
entities: rows.map((row) => toFleetPositionEntity(row, { generatedAt, staleAfterMs })),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFleetPositionPatch(row, { generatedAt = new Date(), staleAfterMs }) {
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.patch.v1",
|
||||
dataProductId: FLEET_POSITIONS_CURRENT.id,
|
||||
operation: "upsert",
|
||||
emittedAt: iso(generatedAt),
|
||||
entity: toFleetPositionEntity(row, { generatedAt, staleAfterMs }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFleetPositionEntity(row, { generatedAt, staleAfterMs }) {
|
||||
const observedAt = date(row.observedAt);
|
||||
const receivedAt = date(row.receivedAt);
|
||||
const latitude = finiteNumber(row.latitude);
|
||||
const longitude = finiteNumber(row.longitude);
|
||||
const hasPosition = latitude !== null && longitude !== null;
|
||||
const ageMs = observedAt ? Math.max(0, generatedAt.getTime() - observedAt.getTime()) : null;
|
||||
const status = !hasPosition ? "no-position" : ageMs !== null && ageMs > staleAfterMs ? "stale" : "active";
|
||||
|
||||
return {
|
||||
subjectId: String(row.subjectId),
|
||||
semanticType: "map.moving_object",
|
||||
label: String(row.name || row.subjectId),
|
||||
status,
|
||||
observedAt: observedAt ? observedAt.toISOString() : null,
|
||||
receivedAt: receivedAt ? receivedAt.toISOString() : null,
|
||||
position: hasPosition ? { latitude, longitude } : null,
|
||||
motion: {
|
||||
speed: finiteNumber(row.speed),
|
||||
course: finiteNumber(row.course),
|
||||
},
|
||||
quality: {
|
||||
gpsValid: booleanOrNull(row.gpsValid),
|
||||
satellites: finiteNumber(row.satellites),
|
||||
hdop: finiteNumber(row.hdop),
|
||||
accuracyM: finiteNumber(row.accuracyM),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function iso(value) {
|
||||
const parsed = date(value);
|
||||
if (!parsed) throw new Error("data_product_timestamp_invalid");
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function date(value) {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function booleanOrNull(value) {
|
||||
if (value === true || value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === false || value === 0 || value === "0" || value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function normalizeUnit(unit, { tenantId, connectionId, receivedAt = new Date() }) {
|
||||
const sourceUnitId = positiveInteger(first(unit?.id, unit?.unit_id, unit?.unitId));
|
||||
if (!sourceUnitId) return null;
|
||||
|
||||
const lastMessage = firstObject(unit?.lmsg, unit?.last_msg, unit?.lastMsg, unit?.lastMessage) || {};
|
||||
const latitude = finiteNumber(first(lastMessage.lat, lastMessage.latitude, unit?.lat, unit?.latitude));
|
||||
const longitude = finiteNumber(first(lastMessage.lon, lastMessage.lng, lastMessage.longitude, unit?.lon, unit?.lng, unit?.longitude));
|
||||
const observedAt = timestamp(first(lastMessage.time, lastMessage.ts, lastMessage.timestamp, unit?.last_msg_time, unit?.lmsg_time), receivedAt);
|
||||
const telemetry = {
|
||||
sourceUnitId,
|
||||
name: optionalString(first(unit?.name, unit?.unit_name)),
|
||||
observedAt: observedAt.toISOString(),
|
||||
latitude,
|
||||
longitude,
|
||||
speed: finiteNumber(first(lastMessage.speed, unit?.speed)),
|
||||
course: finiteNumber(first(lastMessage.course, lastMessage.angle, unit?.course)),
|
||||
gpsValid: booleanOrNull(first(lastMessage.gps_valid, lastMessage.gpsValid, unit?.gps_valid)),
|
||||
satellites: finiteNumber(first(lastMessage.sats, lastMessage.satellites, unit?.sats)),
|
||||
hdop: finiteNumber(first(lastMessage.hdop, unit?.hdop)),
|
||||
accuracyM: finiteNumber(first(lastMessage.accuracy, lastMessage.accuracy_m, unit?.accuracy_m)),
|
||||
};
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
connectionId,
|
||||
sourceUnitId,
|
||||
stableSubjectId: `gelios.unit:${connectionId}:${sourceUnitId}`,
|
||||
name: telemetry.name || `Gelios unit ${sourceUnitId}`,
|
||||
observedAt,
|
||||
receivedAt,
|
||||
latitude,
|
||||
longitude,
|
||||
speed: telemetry.speed,
|
||||
course: telemetry.course,
|
||||
gpsValid: telemetry.gpsValid,
|
||||
satellites: telemetry.satellites,
|
||||
hdop: telemetry.hdop,
|
||||
accuracyM: telemetry.accuracyM,
|
||||
telemetry,
|
||||
fingerprint: hash(telemetry),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractUnitItems(payload) {
|
||||
if (Array.isArray(payload?.items)) return payload.items;
|
||||
if (Array.isArray(payload?.units)) return payload.units;
|
||||
if (Array.isArray(payload?.data)) return payload.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function hash(value) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function first(...values) {
|
||||
return values.find((value) => value !== undefined && value !== null && value !== "");
|
||||
}
|
||||
|
||||
function firstObject(...values) {
|
||||
return values.find((value) => value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function positiveInteger(value) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function booleanOrNull(value) {
|
||||
if (value === true || value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === false || value === 0 || value === "0" || value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function timestamp(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
|
||||
const milliseconds = numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
||||
const parsed = new Date(milliseconds);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
export async function migrate(pool) {
|
||||
await pool.query("create extension if not exists timescaledb");
|
||||
await pool.query("create extension if not exists postgis");
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_connections (
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
provider_id text not null default 'gelios',
|
||||
unit_scope text not null default 'allowlist',
|
||||
allowed_unit_ids jsonb not null default '[]'::jsonb,
|
||||
collection_enabled boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (tenant_id, connection_id)
|
||||
)
|
||||
`);
|
||||
await pool.query("alter table gelios_connections add column if not exists unit_scope text not null default 'allowlist'");
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_collection_runs (
|
||||
id uuid primary key,
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
capability_id text not null,
|
||||
status text not null,
|
||||
unit_count integer not null default 0,
|
||||
inserted_snapshots integer not null default 0,
|
||||
error_code text,
|
||||
started_at timestamptz not null default now(),
|
||||
completed_at timestamptz,
|
||||
foreign key (tenant_id, connection_id)
|
||||
references gelios_connections (tenant_id, connection_id)
|
||||
on delete cascade
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_raw_envelopes (
|
||||
id uuid primary key,
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
collection_run_id uuid references gelios_collection_runs(id) on delete set null,
|
||||
capability_id text not null,
|
||||
payload_hash text not null,
|
||||
payload jsonb not null,
|
||||
payload_bytes integer not null,
|
||||
received_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
unique (tenant_id, connection_id, capability_id, payload_hash)
|
||||
)
|
||||
`);
|
||||
await pool.query("create index if not exists gelios_raw_envelopes_expiry_idx on gelios_raw_envelopes (expires_at)");
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_units (
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
source_unit_id bigint not null,
|
||||
stable_subject_id text not null,
|
||||
name text not null,
|
||||
first_seen_at timestamptz not null default now(),
|
||||
last_seen_at timestamptz not null default now(),
|
||||
primary key (tenant_id, connection_id, source_unit_id),
|
||||
unique (tenant_id, connection_id, stable_subject_id),
|
||||
foreign key (tenant_id, connection_id)
|
||||
references gelios_connections (tenant_id, connection_id)
|
||||
on delete cascade
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_unit_current (
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
source_unit_id bigint not null,
|
||||
stable_subject_id text not null,
|
||||
name text not null,
|
||||
observed_at timestamptz not null,
|
||||
received_at timestamptz not null,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
position geography(Point, 4326),
|
||||
speed double precision,
|
||||
course double precision,
|
||||
gps_valid boolean,
|
||||
satellites double precision,
|
||||
hdop double precision,
|
||||
accuracy_m double precision,
|
||||
telemetry jsonb not null,
|
||||
fingerprint text not null,
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (tenant_id, connection_id, source_unit_id),
|
||||
foreign key (tenant_id, connection_id, source_unit_id)
|
||||
references gelios_units (tenant_id, connection_id, source_unit_id)
|
||||
on delete cascade
|
||||
)
|
||||
`);
|
||||
await pool.query("create index if not exists gelios_unit_current_position_idx on gelios_unit_current using gist (position)");
|
||||
await pool.query("create index if not exists gelios_unit_current_observed_idx on gelios_unit_current (tenant_id, connection_id, observed_at desc)");
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists gelios_telemetry_snapshots (
|
||||
tenant_id text not null,
|
||||
connection_id text not null,
|
||||
source_unit_id bigint not null,
|
||||
observed_at timestamptz not null,
|
||||
received_at timestamptz not null,
|
||||
fingerprint text not null,
|
||||
telemetry jsonb not null,
|
||||
primary key (tenant_id, connection_id, source_unit_id, observed_at, fingerprint)
|
||||
)
|
||||
`);
|
||||
await pool.query("select create_hypertable('gelios_telemetry_snapshots', 'observed_at', if_not_exists => true, migrate_data => true)");
|
||||
await pool.query("create index if not exists gelios_snapshots_unit_time_idx on gelios_telemetry_snapshots (tenant_id, connection_id, source_unit_id, observed_at desc)");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { GELIOS_CAPABILITIES } from "../capabilities.mjs";
|
||||
import { readConfig, unitIsInScope } from "../config.mjs";
|
||||
import { toFleetPositionPatch, toFleetPositionsSnapshot } from "../data-products.mjs";
|
||||
import { extractUnitItems, normalizeUnit } from "../normalize.mjs";
|
||||
|
||||
const unit = normalizeUnit({
|
||||
id: 42,
|
||||
name: "Sample fleet fixture",
|
||||
lmsg: { time: 1_789_123_456, lat: 55.75, lon: 37.61, speed: 12.5, gps_valid: true, sats: 11 },
|
||||
}, { tenantId: "sample-tenant", connectionId: "gelios-sample", receivedAt: new Date("2026-07-13T00:00:00.000Z") });
|
||||
assert.equal(unit.stableSubjectId, "gelios.unit:gelios-sample:42");
|
||||
assert.equal(unit.latitude, 55.75);
|
||||
assert.equal(unit.longitude, 37.61);
|
||||
assert.equal(unit.gpsValid, true);
|
||||
assert.equal(extractUnitItems({ items: [{ id: 1 }] }).length, 1);
|
||||
assert.equal(extractUnitItems({ units: [{ id: 1 }] }).length, 1);
|
||||
assert.equal(GELIOS_CAPABILITIES.some((item) => item.enabled === true && item.class.includes("write")), false);
|
||||
assert.equal(GELIOS_CAPABILITIES.some((item) => item.id === "gelios.commands.send" && item.enabled === false), true);
|
||||
const snapshot = toFleetPositionsSnapshot([unit], {
|
||||
tenantId: "sample-tenant",
|
||||
connectionId: "gelios-sample",
|
||||
generatedAt: new Date("2027-09-01T00:00:00.000Z"),
|
||||
staleAfterMs: 300_000,
|
||||
});
|
||||
assert.equal(snapshot.dataProduct.id, "fleet.positions.current.v1");
|
||||
assert.equal(snapshot.entities[0].semanticType, "map.moving_object");
|
||||
assert.equal(snapshot.entities[0].status, "stale");
|
||||
assert.equal(Object.hasOwn(snapshot.entities[0], "telemetry"), false);
|
||||
assert.equal(toFleetPositionPatch({ ...unit, latitude: null, longitude: null }, {
|
||||
generatedAt: new Date("2026-07-13T00:00:00.000Z"),
|
||||
staleAfterMs: 300_000,
|
||||
}).entity.status, "no-position");
|
||||
|
||||
const configBase = {
|
||||
DATABASE_URL: "postgresql://fixture",
|
||||
GELIOS_TENANT_ID: "fixture-tenant",
|
||||
GELIOS_CONNECTION_ID: "fixture-connection",
|
||||
GELIOS_INTAKE_ENABLED: "true",
|
||||
};
|
||||
const allUnitsConfig = readConfig({ ...configBase, GELIOS_UNIT_SCOPE: "all" });
|
||||
assert.equal(allUnitsConfig.unitScope, "all");
|
||||
assert.equal(unitIsInScope(allUnitsConfig, 1), true);
|
||||
assert.equal(unitIsInScope(allUnitsConfig, 99_999), true);
|
||||
const allowlistConfig = readConfig({ ...configBase, GELIOS_UNIT_SCOPE: "allowlist", GELIOS_ALLOWED_UNIT_IDS: "7,11" });
|
||||
assert.equal(unitIsInScope(allowlistConfig, 7), true);
|
||||
assert.equal(unitIsInScope(allowlistConfig, 8), false);
|
||||
assert.throws(() => readConfig({ ...configBase, GELIOS_UNIT_SCOPE: "everything" }), /GELIOS_UNIT_SCOPE/);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"unit_normalization",
|
||||
"safe_intake_registry",
|
||||
"provider_neutral_data_product",
|
||||
"all_units_connection_scope",
|
||||
"command_transport_absent",
|
||||
],
|
||||
}));
|
||||
@@ -0,0 +1,406 @@
|
||||
import express from "express";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { Pool } from "pg";
|
||||
import { GELIOS_CAPABILITIES } from "./capabilities.mjs";
|
||||
import { readConfig, unitIsInScope } from "./config.mjs";
|
||||
import { FLEET_POSITIONS_CURRENT, toFleetPositionPatch, toFleetPositionsSnapshot } from "./data-products.mjs";
|
||||
import { extractUnitItems, hash, normalizeUnit } from "./normalize.mjs";
|
||||
import { migrate } from "./schema.mjs";
|
||||
|
||||
const config = readConfig();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const streamClients = new Set();
|
||||
let intakeInFlight = null;
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.get("/healthz", asyncRoute(async (_req, res) => {
|
||||
await pool.query("select 1");
|
||||
res.json({
|
||||
ok: true,
|
||||
service: "nodedc-gelios-gateway",
|
||||
database: "ready",
|
||||
internalApiConfigured: Boolean(config.internalAccessToken),
|
||||
intakeEnabled: config.intakeEnabled,
|
||||
unitScope: config.unitScope,
|
||||
credentialOwner: "engine",
|
||||
providerTransport: "absent",
|
||||
commandTransport: "absent",
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/internal/gelios/v1/status", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const scope = requireConnectionScope(req);
|
||||
const latestRun = await pool.query(
|
||||
`select id, capability_id, status, unit_count, inserted_snapshots, error_code, started_at, completed_at
|
||||
from gelios_collection_runs
|
||||
where tenant_id = $1 and connection_id = $2
|
||||
order by started_at desc
|
||||
limit 1`,
|
||||
[scope.tenantId, scope.connectionId],
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
provider: "gelios",
|
||||
tenantId: scope.tenantId,
|
||||
connectionId: scope.connectionId,
|
||||
unitScope: config.unitScope,
|
||||
approvedUnitCount: config.unitScope === "all" ? null : config.allowedUnitIds.length,
|
||||
intakeEnabled: config.intakeEnabled,
|
||||
credentialOwner: "engine",
|
||||
providerTransport: "absent",
|
||||
commandTransport: "absent",
|
||||
lastRun: latestRun.rows[0] || null,
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/internal/gelios/v1/capabilities", requireInternalApi, (_req, res) => {
|
||||
res.json({ ok: true, provider: "gelios", capabilities: GELIOS_CAPABILITIES, commandTransport: "absent" });
|
||||
});
|
||||
|
||||
app.post("/internal/gelios/v1/intake/units", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const scope = requireConnectionScope(req);
|
||||
const result = await runIntake({
|
||||
scope,
|
||||
payload: req.body,
|
||||
receivedAt: parseReceivedAt(req.body?.receivedAt),
|
||||
});
|
||||
res.status(result.status === "completed" ? 200 : 409).json(result);
|
||||
}));
|
||||
|
||||
app.get("/internal/gelios/v1/units/current", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const scope = requireConnectionScope(req);
|
||||
const limit = boundedLimit(req.query.limit, 200, 1, 1000);
|
||||
const rows = await listCurrentRows(scope, limit);
|
||||
res.json({
|
||||
ok: true,
|
||||
contract: "gelios.current-position.v1",
|
||||
tenantId: scope.tenantId,
|
||||
connectionId: scope.connectionId,
|
||||
units: rows,
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/internal/gelios/v1/data-products/fleet.positions.current.v1/snapshot", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const scope = requireConnectionScope(req);
|
||||
const limit = boundedLimit(req.query.limit, 200, 1, 1000);
|
||||
const rows = await listCurrentRows(scope, limit);
|
||||
res.json({
|
||||
ok: true,
|
||||
...toFleetPositionsSnapshot(rows, { ...scope, staleAfterMs: config.positionStaleAfterMs }),
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/internal/gelios/v1/data-products/fleet.positions.current.v1/stream", requireInternalApi, (req, res) => {
|
||||
let scope;
|
||||
try {
|
||||
scope = requireConnectionScope(req);
|
||||
} catch (error) {
|
||||
res.status(Number(error.status || 400)).json({ ok: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
const client = openSseClient(req, res, scope, "data-product");
|
||||
writeSse(res, "nodedc.data-product.ready.v1", {
|
||||
dataProduct: FLEET_POSITIONS_CURRENT,
|
||||
scope,
|
||||
bootstrap: "fetch-snapshot",
|
||||
});
|
||||
return client;
|
||||
});
|
||||
|
||||
app.get("/internal/gelios/v1/stream", requireInternalApi, (req, res) => {
|
||||
let scope;
|
||||
try {
|
||||
scope = requireConnectionScope(req);
|
||||
} catch (error) {
|
||||
res.status(Number(error.status || 400)).json({ ok: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
const client = openSseClient(req, res, scope, "legacy");
|
||||
writeSse(res, "gelios.ready", {
|
||||
ok: true,
|
||||
contract: "gelios.current-position.v1",
|
||||
tenantId: scope.tenantId,
|
||||
connectionId: scope.connectionId,
|
||||
commandTransport: "absent",
|
||||
});
|
||||
return client;
|
||||
});
|
||||
|
||||
async function listCurrentRows(scope, limit) {
|
||||
const rows = await pool.query(
|
||||
`select
|
||||
stable_subject_id as "subjectId", source_unit_id as "sourceUnitId", name,
|
||||
observed_at as "observedAt", received_at as "receivedAt", latitude, longitude,
|
||||
speed, course, gps_valid as "gpsValid", satellites, hdop,
|
||||
accuracy_m as "accuracyM", telemetry
|
||||
from gelios_unit_current
|
||||
where tenant_id = $1 and connection_id = $2
|
||||
order by observed_at desc, source_unit_id asc
|
||||
limit $3`,
|
||||
[scope.tenantId, scope.connectionId, limit],
|
||||
);
|
||||
return rows.rows;
|
||||
}
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
const status = Number(error?.status || 500);
|
||||
const publicStatus = status >= 400 && status < 600 ? status : 500;
|
||||
console.error(JSON.stringify({ event: "gelios_gateway_error", error: safeErrorCode(error), status: publicStatus }));
|
||||
res.status(publicStatus).json({ ok: false, error: publicStatus >= 500 ? "internal_error" : safeErrorCode(error) });
|
||||
});
|
||||
|
||||
await migrate(pool);
|
||||
await upsertConnection();
|
||||
|
||||
httpServer.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`NODE.DC Gelios Gateway listening on http://0.0.0.0:${config.port}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function upsertConnection() {
|
||||
await pool.query(
|
||||
`insert into gelios_connections (
|
||||
tenant_id, connection_id, unit_scope, allowed_unit_ids, collection_enabled, updated_at
|
||||
) values ($1, $2, $3, $4::jsonb, $5, now())
|
||||
on conflict (tenant_id, connection_id) do update
|
||||
set unit_scope = excluded.unit_scope,
|
||||
allowed_unit_ids = excluded.allowed_unit_ids,
|
||||
collection_enabled = excluded.collection_enabled,
|
||||
updated_at = now()`,
|
||||
[config.tenantId, config.connectionId, config.unitScope, JSON.stringify(config.allowedUnitIds), config.intakeEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
async function runIntake({ scope, payload, receivedAt }) {
|
||||
if (!config.intakeEnabled) return { ok: false, status: "disabled" };
|
||||
if (config.unitScope === "allowlist" && !config.allowedUnitIds.length) {
|
||||
return { ok: false, status: "scope_missing" };
|
||||
}
|
||||
if (intakeInFlight) return { ok: false, status: "busy" };
|
||||
intakeInFlight = ingestCurrentUnits({ scope, payload, receivedAt }).finally(() => {
|
||||
intakeInFlight = null;
|
||||
});
|
||||
return intakeInFlight;
|
||||
}
|
||||
|
||||
async function ingestCurrentUnits({ scope, payload, receivedAt }) {
|
||||
const runId = randomUUID();
|
||||
await pool.query(
|
||||
`insert into gelios_collection_runs (id, tenant_id, connection_id, capability_id, status)
|
||||
values ($1, $2, $3, 'gelios.units.current_intake', 'running')`,
|
||||
[runId, scope.tenantId, scope.connectionId],
|
||||
);
|
||||
|
||||
try {
|
||||
const sourcePayload = payload?.units ? { units: payload.units } : payload;
|
||||
const items = extractUnitItems(sourcePayload);
|
||||
if (!items.length) throw Object.assign(new Error("intake_units_required"), { status: 422 });
|
||||
await storeRawEnvelope({ runId, scope, payload: sourcePayload, receivedAt });
|
||||
const normalized = items
|
||||
.map((item) => normalizeUnit(item, { ...scope, receivedAt }))
|
||||
.filter(Boolean)
|
||||
.filter((item) => unitIsInScope(config, item.sourceUnitId));
|
||||
|
||||
let insertedSnapshots = 0;
|
||||
for (const unit of normalized) {
|
||||
const result = await persistCurrentUnit(unit);
|
||||
insertedSnapshots += result.insertedSnapshot ? 1 : 0;
|
||||
if (result.current) broadcastCurrentUnit(result.current, scope.tenantId, scope.connectionId);
|
||||
}
|
||||
await pruneExpiredRawEnvelopes();
|
||||
await pool.query(
|
||||
`update gelios_collection_runs
|
||||
set status = 'completed', unit_count = $2, inserted_snapshots = $3, completed_at = now()
|
||||
where id = $1`,
|
||||
[runId, normalized.length, insertedSnapshots],
|
||||
);
|
||||
return { ok: true, status: "completed", runId, unitCount: normalized.length, insertedSnapshots };
|
||||
} catch (error) {
|
||||
await pool.query(
|
||||
`update gelios_collection_runs
|
||||
set status = 'failed', error_code = $2, completed_at = now()
|
||||
where id = $1`,
|
||||
[runId, safeErrorCode(error)],
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeRawEnvelope({ runId, scope, payload, receivedAt }) {
|
||||
const serialized = JSON.stringify(payload);
|
||||
await pool.query(
|
||||
`insert into gelios_raw_envelopes (
|
||||
id, tenant_id, connection_id, collection_run_id, capability_id,
|
||||
payload_hash, payload, payload_bytes, received_at, expires_at
|
||||
) values ($1, $2, $3, $4, 'gelios.units.current_intake', $5, $6::jsonb, $7, $8, $9)
|
||||
on conflict (tenant_id, connection_id, capability_id, payload_hash) do nothing`,
|
||||
[
|
||||
randomUUID(), scope.tenantId, scope.connectionId, runId, hash(payload), serialized,
|
||||
Buffer.byteLength(serialized), receivedAt,
|
||||
new Date(receivedAt.getTime() + config.rawRetentionDays * 24 * 60 * 60 * 1000),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function persistCurrentUnit(unit) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
await client.query(
|
||||
`insert into gelios_units (
|
||||
tenant_id, connection_id, source_unit_id, stable_subject_id, name, last_seen_at
|
||||
) values ($1, $2, $3, $4, $5, $6)
|
||||
on conflict (tenant_id, connection_id, source_unit_id) do update
|
||||
set stable_subject_id = excluded.stable_subject_id,
|
||||
name = excluded.name,
|
||||
last_seen_at = excluded.last_seen_at`,
|
||||
[unit.tenantId, unit.connectionId, unit.sourceUnitId, unit.stableSubjectId, unit.name, unit.receivedAt],
|
||||
);
|
||||
const current = await client.query(
|
||||
`insert into gelios_unit_current (
|
||||
tenant_id, connection_id, source_unit_id, stable_subject_id, name,
|
||||
observed_at, received_at, latitude, longitude, position, speed, course,
|
||||
gps_valid, satellites, hdop, accuracy_m, telemetry, fingerprint
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
case when $8::double precision is not null and $9::double precision is not null
|
||||
then ST_SetSRID(ST_MakePoint($9, $8), 4326)::geography else null end,
|
||||
$10, $11, $12, $13, $14, $15, $16::jsonb, $17
|
||||
)
|
||||
on conflict (tenant_id, connection_id, source_unit_id) do update
|
||||
set stable_subject_id = excluded.stable_subject_id,
|
||||
name = excluded.name,
|
||||
observed_at = excluded.observed_at,
|
||||
received_at = excluded.received_at,
|
||||
latitude = excluded.latitude,
|
||||
longitude = excluded.longitude,
|
||||
position = excluded.position,
|
||||
speed = excluded.speed,
|
||||
course = excluded.course,
|
||||
gps_valid = excluded.gps_valid,
|
||||
satellites = excluded.satellites,
|
||||
hdop = excluded.hdop,
|
||||
accuracy_m = excluded.accuracy_m,
|
||||
telemetry = excluded.telemetry,
|
||||
fingerprint = excluded.fingerprint,
|
||||
updated_at = now()
|
||||
where excluded.observed_at >= gelios_unit_current.observed_at
|
||||
returning stable_subject_id as "subjectId", source_unit_id as "sourceUnitId", name,
|
||||
observed_at as "observedAt", received_at as "receivedAt", latitude, longitude,
|
||||
speed, course, gps_valid as "gpsValid", satellites, hdop, accuracy_m as "accuracyM", telemetry`,
|
||||
[
|
||||
unit.tenantId, unit.connectionId, unit.sourceUnitId, unit.stableSubjectId, unit.name,
|
||||
unit.observedAt, unit.receivedAt, unit.latitude, unit.longitude, unit.speed, unit.course,
|
||||
unit.gpsValid, unit.satellites, unit.hdop, unit.accuracyM, JSON.stringify(unit.telemetry), unit.fingerprint,
|
||||
],
|
||||
);
|
||||
const snapshot = await client.query(
|
||||
`insert into gelios_telemetry_snapshots (
|
||||
tenant_id, connection_id, source_unit_id, observed_at, received_at, fingerprint, telemetry
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
on conflict do nothing`,
|
||||
[unit.tenantId, unit.connectionId, unit.sourceUnitId, unit.observedAt, unit.receivedAt, unit.fingerprint, JSON.stringify(unit.telemetry)],
|
||||
);
|
||||
await client.query("commit");
|
||||
return { current: current.rows[0] || null, insertedSnapshot: snapshot.rowCount > 0 };
|
||||
} catch (error) {
|
||||
await client.query("rollback");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneExpiredRawEnvelopes() {
|
||||
await pool.query("delete from gelios_raw_envelopes where expires_at < now()");
|
||||
}
|
||||
|
||||
function requireInternalApi(req, res, next) {
|
||||
const expected = Buffer.from(config.internalAccessToken || "");
|
||||
const received = Buffer.from(String(req.get("authorization") || "").replace(/^Bearer\s+/i, "").trim());
|
||||
if (!expected.length) {
|
||||
res.status(503).json({ ok: false, error: "internal_api_not_configured" });
|
||||
return;
|
||||
}
|
||||
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
||||
res.status(401).json({ ok: false, error: "internal_authorization_required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireConnectionScope(req) {
|
||||
const tenantId = String(req.get("x-nodedc-tenant-id") || "").trim();
|
||||
const connectionId = String(req.query.connectionId || req.get("x-nodedc-connection-id") || config.connectionId).trim();
|
||||
if (tenantId !== config.tenantId) throw Object.assign(new Error("tenant_scope_required"), { status: 403 });
|
||||
if (connectionId !== config.connectionId) throw Object.assign(new Error("connection_scope_denied"), { status: 403 });
|
||||
return { tenantId, connectionId };
|
||||
}
|
||||
|
||||
function broadcastCurrentUnit(unit, tenantId, connectionId) {
|
||||
for (const client of streamClients) {
|
||||
if (client.tenantId === tenantId && client.connectionId === connectionId) {
|
||||
if (client.kind === "data-product") {
|
||||
writeSse(client.res, "nodedc.data-product.patch.v1", toFleetPositionPatch(unit, {
|
||||
staleAfterMs: config.positionStaleAfterMs,
|
||||
}));
|
||||
} else {
|
||||
writeSse(client.res, "gelios.current.updated", unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSseClient(req, res, scope, kind) {
|
||||
const client = { id: randomUUID(), res, kind, ...scope };
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 30000);
|
||||
streamClients.add(client);
|
||||
req.on("close", () => {
|
||||
clearInterval(keepAlive);
|
||||
streamClients.delete(client);
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function writeSse(res, event, payload) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function parseReceivedAt(value) {
|
||||
if (value === undefined || value === null || value === "") return new Date();
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) throw Object.assign(new Error("intake_received_at_invalid"), { status: 422 });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boundedLimit(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
if (!Number.isInteger(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
return String(error?.message || "internal_error").replace(/[^a-zA-Z0-9_:.-]+/g, "_").slice(0, 160);
|
||||
}
|
||||
|
||||
function asyncRoute(handler) {
|
||||
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
await new Promise((resolve) => httpServer.close(resolve));
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user