407 lines
16 KiB
JavaScript
407 lines
16 KiB
JavaScript
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);
|
|
}
|