feat(data-plane): add provider contracts and ontology delivery

This commit is contained in:
Codex
2026-07-16 02:23:34 +03:00
parent e527812826
commit 569b8762e6
84 changed files with 11170 additions and 70 deletions
+23
View File
@@ -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"]
+70
View File
@@ -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
```
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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.",
},
]);
+80
View File
@@ -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;
}
+92
View File
@@ -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;
}
+116
View File
@@ -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",
],
}));
+406
View File
@@ -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);
}