feat(map): ingest audited department zone snapshot
This commit is contained in:
parent
3446f3edc2
commit
343812b90d
|
|
@ -18,10 +18,12 @@ EXPECTED_ENTRIES = [
|
||||||
"platform/packages/external-provider-contract/src/contract-version.mjs",
|
"platform/packages/external-provider-contract/src/contract-version.mjs",
|
||||||
"platform/packages/external-provider-contract/src/data-plane.mjs",
|
"platform/packages/external-provider-contract/src/data-plane.mjs",
|
||||||
"platform/packages/external-provider-contract/src/data-product.mjs",
|
"platform/packages/external-provider-contract/src/data-product.mjs",
|
||||||
|
"platform/packages/external-provider-contract/src/geometry.mjs",
|
||||||
"platform/packages/external-provider-contract/src/intake-batch.mjs",
|
"platform/packages/external-provider-contract/src/intake-batch.mjs",
|
||||||
"platform/packages/external-provider-contract/src/index.mjs",
|
"platform/packages/external-provider-contract/src/index.mjs",
|
||||||
"platform/packages/external-provider-contract/src/provider-package.mjs",
|
"platform/packages/external-provider-contract/src/provider-package.mjs",
|
||||||
"platform/packages/external-provider-contract/src/sensitive-field-policy.mjs",
|
"platform/packages/external-provider-contract/src/sensitive-field-policy.mjs",
|
||||||
|
"platform/packages/external-provider-contract/src/zone-source.mjs",
|
||||||
"platform/packages/external-provider-contract/providers/gelios",
|
"platform/packages/external-provider-contract/providers/gelios",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
# Gelios provider package v6
|
# Gelios provider package v6
|
||||||
|
|
||||||
Adds the bounded, offset-paged `gelios.geozone` read capability and maps a
|
Adds the bounded, offset-paged `gelios.geozone` read capability and maps a
|
||||||
complete accepted source generation into the provider-neutral
|
complete account generation into `map.gelios-geofences.current.v1`.
|
||||||
`map.zones.current.v1` Data Product. The runtime publisher must use replace
|
|
||||||
mode: a partial provider page set never reaches Publish, while a complete newer
|
|
||||||
generation atomically upserts present zones and emits remove patches for zones
|
|
||||||
that disappeared.
|
|
||||||
|
|
||||||
`gelios-rest-v1` and `mmap-snapshot-v1` are source adapters, not Foundry
|
Gelios account geo-objects are deliberately not published into the normative
|
||||||
bindings. Both must preserve the native Gelios zone id (or use an explicit
|
`map.zones.current.v1` Department of Transport dataset. Independent authorities
|
||||||
crosswalk) and produce the same `map.zone` facts. Foundry only consumes the
|
must never share one replace generation.
|
||||||
provider-neutral product.
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { geliosProviderPackageV5 } from "../v5/package.mjs";
|
||||||
|
|
||||||
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v6";
|
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v6";
|
||||||
export const GELIOS_PROVIDER_PACKAGE_VERSION = "6.0.0";
|
export const GELIOS_PROVIDER_PACKAGE_VERSION = "6.0.0";
|
||||||
export const GELIOS_ZONES_DATA_PRODUCT_ID = "map.zones.current.v1";
|
export const GELIOS_ZONES_DATA_PRODUCT_ID = "map.gelios-geofences.current.v1";
|
||||||
export const GELIOS_ZONES_DATA_PRODUCT_VERSION = "1.0.0";
|
export const GELIOS_ZONES_DATA_PRODUCT_VERSION = "1.0.0";
|
||||||
export const GELIOS_ZONES_ONTOLOGY_REVISION = "ontology.map.zone.v1";
|
export const GELIOS_ZONES_ONTOLOGY_REVISION = "ontology.map.zone.v1";
|
||||||
export const GELIOS_ZONE_SOURCE_ID_PREFIX = "gelios-zone-";
|
export const GELIOS_ZONE_SOURCE_ID_PREFIX = "gelios-zone-";
|
||||||
|
|
@ -10,7 +10,7 @@ export const GELIOS_ZONE_SOURCE_ID_PREFIX = "gelios-zone-";
|
||||||
const ZONES_CAPABILITY_ID = "gelios.geozones.current.read";
|
const ZONES_CAPABILITY_ID = "gelios.geozones.current.read";
|
||||||
const ZONES_FIELD_POLICY_ID = "gelios.geozones.current.fields.v1";
|
const ZONES_FIELD_POLICY_ID = "gelios.geozones.current.fields.v1";
|
||||||
const ZONES_PROFILE_ID = "gelios.geozones.current.realtime.v1";
|
const ZONES_PROFILE_ID = "gelios.geozones.current.realtime.v1";
|
||||||
const ZONES_MAPPING_ID = "gelios.geozones.to.map.zones.current.v1";
|
const ZONES_MAPPING_ID = "gelios.geozones.to.map.gelios-geofences.current.v1";
|
||||||
const ZONES_TEMPLATE_ID = "gelios.geozones.current.l2.v1";
|
const ZONES_TEMPLATE_ID = "gelios.geozones.current.l2.v1";
|
||||||
|
|
||||||
const value = structuredClone(geliosProviderPackageV5);
|
const value = structuredClone(geliosProviderPackageV5);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,29 @@ import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import { isBoundedGeoJsonGeometry } from "./geometry.mjs";
|
import { isBoundedGeoJsonGeometry } from "./geometry.mjs";
|
||||||
|
|
||||||
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v1";
|
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
|
||||||
export const ZONE_SOURCE_ADAPTERS = Object.freeze({
|
export const ZONE_SOURCE_ADAPTERS = Object.freeze({
|
||||||
"gelios-rest-v1": Object.freeze({ sourceKind: "live_api", sourceRevision: "gelios-rest-v1" }),
|
"depttrans-api-snapshot-v1": Object.freeze({
|
||||||
"mmap-snapshot-v1": Object.freeze({ sourceKind: "versioned_snapshot", sourceRevision: "mmap-snapshot-v1" }),
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
sourceIdPrefix: "depttrans-pmd-slow-zone-",
|
||||||
|
sourceKind: "versioned_snapshot",
|
||||||
|
sourceRevision: "depttrans-api-snapshot-v1",
|
||||||
|
}),
|
||||||
|
"depttrans-mmap-snapshot-v1": Object.freeze({
|
||||||
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
sourceIdPrefix: "depttrans-pmd-slow-zone-",
|
||||||
|
sourceKind: "versioned_snapshot",
|
||||||
|
sourceRevision: "depttrans-mmap-snapshot-v1",
|
||||||
|
}),
|
||||||
|
"gelios-rest-v1": Object.freeze({
|
||||||
|
authorityId: "gelios-account",
|
||||||
|
datasetId: "gelios-geofences",
|
||||||
|
sourceIdPrefix: "gelios-zone-",
|
||||||
|
sourceKind: "live_api",
|
||||||
|
sourceRevision: "gelios-rest-v1",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,6 +43,8 @@ export function normalizeZoneSourceGeneration(value, {
|
||||||
}
|
}
|
||||||
const adapter = ZONE_SOURCE_ADAPTERS[value.adapterId];
|
const adapter = ZONE_SOURCE_ADAPTERS[value.adapterId];
|
||||||
if (!adapter) throw zoneSourceError("zone_source_adapter_unsupported");
|
if (!adapter) throw zoneSourceError("zone_source_adapter_unsupported");
|
||||||
|
if (value.authorityId !== adapter.authorityId) throw zoneSourceError("zone_source_authority_mismatch");
|
||||||
|
if (value.datasetId !== adapter.datasetId) throw zoneSourceError("zone_source_dataset_mismatch");
|
||||||
if (!isPlainObject(identityCrosswalk)) throw zoneSourceError("zone_source_identity_crosswalk_invalid");
|
if (!isPlainObject(identityCrosswalk)) throw zoneSourceError("zone_source_identity_crosswalk_invalid");
|
||||||
if (value.complete !== true) throw zoneSourceError("zone_source_generation_incomplete");
|
if (value.complete !== true) throw zoneSourceError("zone_source_generation_incomplete");
|
||||||
const generatedAt = iso(value.generatedAt, "zone_source_generated_at_invalid");
|
const generatedAt = iso(value.generatedAt, "zone_source_generated_at_invalid");
|
||||||
|
|
@ -38,7 +59,7 @@ export function normalizeZoneSourceGeneration(value, {
|
||||||
const facts = [];
|
const facts = [];
|
||||||
const sourceIds = new Set();
|
const sourceIds = new Set();
|
||||||
for (const [index, rawZone] of value.zones.entries()) {
|
for (const [index, rawZone] of value.zones.entries()) {
|
||||||
const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId);
|
const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId, adapter);
|
||||||
if (sourceIds.has(zone.sourceId)) throw zoneSourceError("zone_source_identity_duplicate");
|
if (sourceIds.has(zone.sourceId)) throw zoneSourceError("zone_source_identity_duplicate");
|
||||||
sourceIds.add(zone.sourceId);
|
sourceIds.add(zone.sourceId);
|
||||||
facts.push({
|
facts.push({
|
||||||
|
|
@ -47,15 +68,29 @@ export function normalizeZoneSourceGeneration(value, {
|
||||||
observedAt: generatedAt,
|
observedAt: generatedAt,
|
||||||
geometry: zone.geometry,
|
geometry: zone.geometry,
|
||||||
attributes: compact({
|
attributes: compact({
|
||||||
area_square_meters: finiteNonNegative(first(rawZone.surfaceArea, rawZone.surface_area)),
|
area_square_meters: finiteNonNegative(first(rawZone.areaSquareMeters, rawZone.surfaceArea, rawZone.surface_area, rawZone.area)),
|
||||||
description: optionalString(first(rawZone.description, rawZone.descr)),
|
description: optionalString(first(rawZone.description, rawZone.descr)),
|
||||||
display_name: requiredString(first(rawZone.name, rawZone.display_name), "zone_source_name_required"),
|
display_name: requiredString(first(rawZone.displayName, rawZone.name, rawZone.display_name), "zone_source_name_required"),
|
||||||
|
applies_to_couriers: optionalBoolean(first(rawZone.appliesToCouriers, rawZone.courier)),
|
||||||
|
applies_to_kicksharing: optionalBoolean(first(rawZone.appliesToKicksharing, rawZone.kick_sharing)),
|
||||||
|
dataset_authority: adapter.authorityId,
|
||||||
|
dataset_id: adapter.datasetId,
|
||||||
geometry_kind: zone.geometryKind,
|
geometry_kind: zone.geometryKind,
|
||||||
max_speed_kph: finiteNonNegative(first(rawZone.maxPermissibleSpeed, rawZone.max_permissible_speed)),
|
max_speed_kph: finiteNonNegative(first(
|
||||||
|
rawZone.maxSpeedKph,
|
||||||
|
rawZone.max_speed_kph,
|
||||||
|
rawZone.maxPermissibleSpeed,
|
||||||
|
rawZone.max_permissible_speed,
|
||||||
|
)),
|
||||||
perimeter_meters: finiteNonNegative(rawZone.perimeter),
|
perimeter_meters: finiteNonNegative(rawZone.perimeter),
|
||||||
|
schedule_timezone: optionalString(first(rawZone.scheduleTimezone, value.timezone)),
|
||||||
|
source_adapter: value.adapterId,
|
||||||
|
source_created_at: optionalString(first(rawZone.sourceCreatedAt, rawZone.create_dttm)),
|
||||||
source_kind: adapter.sourceKind,
|
source_kind: adapter.sourceKind,
|
||||||
source_revision: revision(value, adapter),
|
source_revision: revision(value, adapter),
|
||||||
|
source_updated_at: optionalString(first(rawZone.sourceUpdatedAt, rawZone.update_dttm)),
|
||||||
style_color: optionalString(first(rawZone.color, rawZone.style_color)),
|
style_color: optionalString(first(rawZone.color, rawZone.style_color)),
|
||||||
|
weekly_speed_schedule: normalizeWeeklySchedule(rawZone.weeklySpeedSchedule),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -78,12 +113,12 @@ export function normalizeZoneSourceGeneration(value, {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId) {
|
function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId, adapter) {
|
||||||
if (!isPlainObject(value)) throw zoneSourceError(`zone_source_zone_${index}_invalid`);
|
if (!isPlainObject(value)) throw zoneSourceError(`zone_source_zone_${index}_invalid`);
|
||||||
const nativeId = resolveNativeId(value, identityCrosswalk, adapterId);
|
const nativeId = resolveNativeId(value, identityCrosswalk, adapterId);
|
||||||
const sourceId = nativeId.startsWith("gelios-zone-") ? nativeId : `gelios-zone-${nativeId}`;
|
const sourceId = nativeId.startsWith(adapter.sourceIdPrefix) ? nativeId : `${adapter.sourceIdPrefix}${nativeId}`;
|
||||||
if (!/^[a-z][a-z0-9._:-]{2,127}$/.test(sourceId)) throw zoneSourceError("zone_source_identity_invalid");
|
if (!/^[a-z][a-z0-9._:-]{2,127}$/.test(sourceId)) throw zoneSourceError("zone_source_identity_invalid");
|
||||||
const rawType = String(first(value.type, value.geometry_kind, "polygon")).trim().toLowerCase();
|
const rawType = String(first(value.type, value.geometry?.type, value.geometry_kind, "polygon")).trim().toLowerCase();
|
||||||
const geometryKind = rawType === "circle" ? "circle" : rawType === "line" || rawType === "corridor" ? "corridor" : "polygon";
|
const geometryKind = rawType === "circle" ? "circle" : rawType === "line" || rawType === "corridor" ? "corridor" : "polygon";
|
||||||
const geometry = normalizeGeometry(value, geometryKind, circleSegments);
|
const geometry = normalizeGeometry(value, geometryKind, circleSegments);
|
||||||
if (!isBoundedGeoJsonGeometry(geometry, new Set(["Polygon", "MultiPolygon"]))) {
|
if (!isBoundedGeoJsonGeometry(geometry, new Set(["Polygon", "MultiPolygon"]))) {
|
||||||
|
|
@ -93,19 +128,23 @@ function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapt
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNativeId(zone, identityCrosswalk, adapterId) {
|
function resolveNativeId(zone, identityCrosswalk, adapterId) {
|
||||||
if (adapterId === "mmap-snapshot-v1") {
|
const direct = first(
|
||||||
const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key));
|
zone.nativeId,
|
||||||
if (!snapshotKey || !Object.hasOwn(identityCrosswalk, snapshotKey)) {
|
zone.pmd_slow_zone_id,
|
||||||
throw zoneSourceError("zone_source_identity_crosswalk_required");
|
zone.id,
|
||||||
}
|
zone.geozoneId,
|
||||||
const mapped = identityCrosswalk[snapshotKey];
|
zone.zone_id,
|
||||||
if (mapped === undefined || mapped === null || !String(mapped).trim()) {
|
zone.sourceId,
|
||||||
throw zoneSourceError("zone_source_identity_crosswalk_required");
|
);
|
||||||
}
|
|
||||||
return String(mapped).trim();
|
|
||||||
}
|
|
||||||
const direct = first(zone.id, zone.geozoneId, zone.zone_id, zone.sourceId);
|
|
||||||
if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim();
|
if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim();
|
||||||
|
if (adapterId === "depttrans-mmap-snapshot-v1" || adapterId === "depttrans-api-snapshot-v1") {
|
||||||
|
const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key));
|
||||||
|
if (snapshotKey && Object.hasOwn(identityCrosswalk, snapshotKey)) {
|
||||||
|
const mapped = identityCrosswalk[snapshotKey];
|
||||||
|
if (mapped !== undefined && mapped !== null && String(mapped).trim()) return String(mapped).trim();
|
||||||
|
}
|
||||||
|
throw zoneSourceError("zone_source_identity_crosswalk_required");
|
||||||
|
}
|
||||||
throw zoneSourceError("zone_source_native_identity_required");
|
throw zoneSourceError("zone_source_native_identity_required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,8 +240,8 @@ function corridorPolygon(points, widthMeters) {
|
||||||
|
|
||||||
function revision(value, adapter) {
|
function revision(value, adapter) {
|
||||||
const explicit = optionalString(value.sourceRevision);
|
const explicit = optionalString(value.sourceRevision);
|
||||||
if (value.adapterId === "mmap-snapshot-v1") {
|
if (adapter.sourceKind === "versioned_snapshot") {
|
||||||
const digest = optionalString(value.snapshotDigest);
|
const digest = optionalString(first(value.contentDigest, value.snapshotDigest));
|
||||||
if (!digest || !/^[a-f0-9]{64}$/.test(digest)) throw zoneSourceError("zone_source_snapshot_digest_required");
|
if (!digest || !/^[a-f0-9]{64}$/.test(digest)) throw zoneSourceError("zone_source_snapshot_digest_required");
|
||||||
return `${explicit || adapter.sourceRevision}@sha256:${digest}`;
|
return `${explicit || adapter.sourceRevision}@sha256:${digest}`;
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +271,30 @@ function finiteNonNegative(value) {
|
||||||
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalBoolean(value) {
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklySchedule(value) {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (!Array.isArray(value)) throw zoneSourceError("zone_source_weekly_schedule_invalid");
|
||||||
|
const entries = value.map((entry) => {
|
||||||
|
if (!isPlainObject(entry)) throw zoneSourceError("zone_source_weekly_schedule_invalid");
|
||||||
|
const day = Number(entry.dayOfWeek);
|
||||||
|
const speed = Number(entry.speedLimitKph);
|
||||||
|
const start = optionalString(entry.startTime);
|
||||||
|
const end = optionalString(entry.endTime);
|
||||||
|
if (!Number.isInteger(day) || day < 1 || day > 7
|
||||||
|
|| !Number.isFinite(speed) || speed < 0 || speed > 200
|
||||||
|
|| !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(start || "")
|
||||||
|
|| !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(end || "")) {
|
||||||
|
throw zoneSourceError("zone_source_weekly_schedule_invalid");
|
||||||
|
}
|
||||||
|
return `${day}@${start}-${end}=${speed}`;
|
||||||
|
});
|
||||||
|
return [...new Set(entries)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
function requiredString(value, code) {
|
function requiredString(value, code) {
|
||||||
const result = optionalString(value);
|
const result = optionalString(value);
|
||||||
if (!result) throw zoneSourceError(code);
|
if (!result) throw zoneSourceError(code);
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,25 @@ assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, e
|
||||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
|
||||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] });
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] });
|
||||||
|
|
||||||
const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.zones.current.v1");
|
const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.gelios-geofences.current.v1");
|
||||||
const registeredZonesProduct = JSON.parse(await readFile(new URL(
|
const registeredZonesProduct = JSON.parse(await readFile(new URL(
|
||||||
"../../../services/external-data-plane/definitions/map.zones.current.v1.json",
|
"../../../services/external-data-plane/definitions/map.zones.current.v1.json",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
), "utf8"));
|
), "utf8"));
|
||||||
assert.deepEqual(zonesProduct, registeredZonesProduct);
|
|
||||||
assert.deepEqual(zonesProduct.semanticTypes, ["map.zone"]);
|
assert.deepEqual(zonesProduct.semanticTypes, ["map.zone"]);
|
||||||
assert.equal(zonesProduct.fieldContracts.geometry.type, "geometry");
|
assert.equal(zonesProduct.fieldContracts.geometry.type, "geometry");
|
||||||
|
assert.equal(registeredZonesProduct.version, "1.1.0");
|
||||||
|
assert.deepEqual(registeredZonesProduct.fieldContracts.dataset_authority.enum, ["moscow-department-of-transport"]);
|
||||||
|
assert.deepEqual(registeredZonesProduct.fieldContracts.source_adapter.enum, [
|
||||||
|
"depttrans-mmap-snapshot-v1",
|
||||||
|
"depttrans-api-snapshot-v1",
|
||||||
|
]);
|
||||||
const zonesCapability = geliosProviderPackageV6.capabilities.find((capability) => capability.id === "gelios.geozones.current.read");
|
const zonesCapability = geliosProviderPackageV6.capabilities.find((capability) => capability.id === "gelios.geozones.current.read");
|
||||||
assert.equal(zonesCapability.request.query.pl, 100);
|
assert.equal(zonesCapability.request.query.pl, 100);
|
||||||
assert.equal(zonesCapability.request.query.po, 0);
|
assert.equal(zonesCapability.request.query.po, 0);
|
||||||
assert.equal(zonesCapability.request.response.pagination.maxItems, 5000);
|
assert.equal(zonesCapability.request.response.pagination.maxItems, 5000);
|
||||||
assert.equal(zonesCapability.request.response.pagination.totalPath, "paginationMetadata.totalCount");
|
assert.equal(zonesCapability.request.response.pagination.totalPath, "paginationMetadata.totalCount");
|
||||||
const zonesMapping = geliosProviderPackageV6.mappingContracts.find((mapping) => mapping.id === "gelios.geozones.to.map.zones.current.v1");
|
const zonesMapping = geliosProviderPackageV6.mappingContracts.find((mapping) => mapping.id === "gelios.geozones.to.map.gelios-geofences.current.v1");
|
||||||
assert.deepEqual(zonesMapping.fact.observedAt.paths, ["updatedAt"]);
|
assert.deepEqual(zonesMapping.fact.observedAt.paths, ["updatedAt"]);
|
||||||
assert.equal(zonesMapping.fact.observedAt.fallback, "collection_received_at");
|
assert.equal(zonesMapping.fact.observedAt.fallback, "collection_received_at");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,157 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
normalizeZoneSourceGeneration,
|
normalizeZoneSourceGeneration,
|
||||||
validateDataProductPublish,
|
validateDataProductPublish,
|
||||||
} from "../src/index.mjs";
|
} from "../src/index.mjs";
|
||||||
|
import { loadZoneSourceProfile } from "../../../services/map-gateway/src/zone-source-snapshot.mjs";
|
||||||
|
|
||||||
const generatedAt = "2026-07-20T18:00:00.000Z";
|
const generatedAt = "2025-06-03T11:05:00.000Z";
|
||||||
const live = normalizeZoneSourceGeneration({
|
const snapshotDigest = "a".repeat(64);
|
||||||
|
const snapshot = normalizeZoneSourceGeneration({
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
adapterId: "gelios-rest-v1",
|
profileId: "moscow-pmd-slow-zones",
|
||||||
sourceRevision: "gelios-rest-v1",
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
adapterId: "depttrans-mmap-snapshot-v1",
|
||||||
|
sourceRevision: "pmd-slow-zones-202506031405",
|
||||||
|
contentDigest: snapshotDigest,
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
timezone: "Europe/Moscow",
|
||||||
complete: true,
|
complete: true,
|
||||||
zones: [{
|
zones: [{
|
||||||
id: 42,
|
nativeId: "1478",
|
||||||
name: "Polygon zone",
|
displayName: "Петровский парк_6 (15 км/ч)",
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[
|
||||||
|
[37.60, 55.74],
|
||||||
|
[37.62, 55.74],
|
||||||
|
[37.62, 55.76],
|
||||||
|
[37.60, 55.74],
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
areaSquareMeters: 75689,
|
||||||
|
appliesToKicksharing: false,
|
||||||
|
appliesToCouriers: true,
|
||||||
|
maxSpeedKph: 15,
|
||||||
|
weeklySpeedSchedule: [{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
startTime: "00:00:00",
|
||||||
|
endTime: "23:59:00",
|
||||||
|
speedLimitKph: 15,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(snapshot.complete, true);
|
||||||
|
assert.equal(snapshot.sourceKind, "versioned_snapshot");
|
||||||
|
assert.equal(snapshot.facts.length, 1);
|
||||||
|
assert.equal(snapshot.facts[0].sourceId, "depttrans-pmd-slow-zone-1478");
|
||||||
|
assert.equal(snapshot.facts[0].observedAt, generatedAt);
|
||||||
|
assert.equal(snapshot.facts[0].geometry.type, "Polygon");
|
||||||
|
assert.equal(snapshot.facts[0].attributes.dataset_authority, "moscow-department-of-transport");
|
||||||
|
assert.equal(snapshot.facts[0].attributes.dataset_id, "pmd-slow-zones");
|
||||||
|
assert.equal(snapshot.facts[0].attributes.source_adapter, "depttrans-mmap-snapshot-v1");
|
||||||
|
assert.deepEqual(snapshot.facts[0].attributes.weekly_speed_schedule, ["1@00:00:00-23:59:00=15"]);
|
||||||
|
assert.match(snapshot.sourceRevision, new RegExp(`@sha256:${snapshotDigest}$`));
|
||||||
|
assert.equal(validateDataProductPublish({
|
||||||
|
schemaVersion: "nodedc.data-product.publish/v1",
|
||||||
|
batch: {
|
||||||
|
runId: "zones-snapshot-1",
|
||||||
|
sequence: 0,
|
||||||
|
idempotencyKey: "zones-snapshot-1.batch-0",
|
||||||
|
mode: "replace",
|
||||||
|
generationAt: generatedAt,
|
||||||
|
},
|
||||||
|
facts: snapshot.facts,
|
||||||
|
}).ok, true);
|
||||||
|
|
||||||
|
const apiSnapshot = normalizeZoneSourceGeneration({
|
||||||
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
adapterId: "depttrans-api-snapshot-v1",
|
||||||
|
sourceRevision: "pmd-slow-zones-20260721",
|
||||||
|
contentDigest: "b".repeat(64),
|
||||||
|
generatedAt: "2026-07-21T09:00:00.000Z",
|
||||||
|
timezone: "Europe/Moscow",
|
||||||
|
complete: true,
|
||||||
|
zones: [{
|
||||||
|
nativeId: "1478",
|
||||||
|
displayName: "Петровский парк_6 (15 км/ч)",
|
||||||
|
geometry: snapshot.facts[0].geometry,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(apiSnapshot.facts[0].sourceId, snapshot.facts[0].sourceId);
|
||||||
|
assert.equal(apiSnapshot.facts[0].attributes.source_adapter, "depttrans-api-snapshot-v1");
|
||||||
|
|
||||||
|
const gelios = normalizeZoneSourceGeneration({
|
||||||
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
|
authorityId: "gelios-account",
|
||||||
|
datasetId: "gelios-geofences",
|
||||||
|
adapterId: "gelios-rest-v1",
|
||||||
|
sourceRevision: "gelios-rest-v1",
|
||||||
|
generatedAt: "2026-07-21T09:00:00.000Z",
|
||||||
|
complete: true,
|
||||||
|
zones: [{
|
||||||
|
id: 1478,
|
||||||
|
name: "Account geofence",
|
||||||
type: "polygon",
|
type: "polygon",
|
||||||
points: [
|
points: [
|
||||||
{ latitude: 55.74, longitude: 37.60 },
|
{ latitude: 55.74, longitude: 37.60 },
|
||||||
{ latitude: 55.74, longitude: 37.62 },
|
{ latitude: 55.74, longitude: 37.62 },
|
||||||
{ latitude: 55.76, longitude: 37.62 },
|
{ latitude: 55.76, longitude: 37.62 },
|
||||||
],
|
],
|
||||||
color: "#ff00ff",
|
|
||||||
}, {
|
|
||||||
id: 43,
|
|
||||||
name: "Circle zone",
|
|
||||||
type: "circle",
|
|
||||||
points: [{ latitude: 55.75, longitude: 37.61 }],
|
|
||||||
radius: 150,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
assert.equal(live.complete, true);
|
assert.equal(gelios.facts[0].sourceId, "gelios-zone-1478");
|
||||||
assert.equal(live.sourceKind, "live_api");
|
assert.notEqual(gelios.facts[0].sourceId, snapshot.facts[0].sourceId);
|
||||||
assert.equal(live.facts.length, 2);
|
|
||||||
assert.deepEqual(live.facts.map((fact) => fact.sourceId), ["gelios-zone-42", "gelios-zone-43"]);
|
|
||||||
assert.equal(live.facts.every((fact) => fact.observedAt === generatedAt), true);
|
|
||||||
assert.equal(live.facts.every((fact) => fact.geometry.type === "Polygon"), true);
|
|
||||||
assert.equal(validateDataProductPublish({
|
|
||||||
schemaVersion: "nodedc.data-product.publish/v1",
|
|
||||||
batch: { runId: "zones-live-1", sequence: 0, idempotencyKey: "zones-live-1.batch-0", mode: "replace", generationAt: generatedAt },
|
|
||||||
facts: live.facts,
|
|
||||||
}).ok, true);
|
|
||||||
|
|
||||||
const snapshotDigest = "a".repeat(64);
|
|
||||||
const snapshot = normalizeZoneSourceGeneration({
|
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
|
||||||
adapterId: "mmap-snapshot-v1",
|
|
||||||
snapshotDigest,
|
|
||||||
generatedAt,
|
|
||||||
complete: true,
|
|
||||||
zones: [{
|
|
||||||
snapshotKey: "legacy-zone-a",
|
|
||||||
name: "Snapshot zone",
|
|
||||||
type: "polygon",
|
|
||||||
points: "55.74,37.60;55.74,37.62;55.76,37.62",
|
|
||||||
}],
|
|
||||||
}, { identityCrosswalk: { "legacy-zone-a": 42 } });
|
|
||||||
assert.equal(snapshot.sourceKind, "versioned_snapshot");
|
|
||||||
assert.equal(snapshot.facts[0].sourceId, live.facts[0].sourceId);
|
|
||||||
assert.match(snapshot.sourceRevision, new RegExp(`@sha256:${snapshotDigest}$`));
|
|
||||||
|
|
||||||
assert.throws(() => normalizeZoneSourceGeneration({
|
assert.throws(() => normalizeZoneSourceGeneration({
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
adapterId: "mmap-snapshot-v1",
|
authorityId: "gelios-account",
|
||||||
snapshotDigest,
|
datasetId: "pmd-slow-zones",
|
||||||
|
adapterId: "depttrans-mmap-snapshot-v1",
|
||||||
|
contentDigest: snapshotDigest,
|
||||||
generatedAt,
|
generatedAt,
|
||||||
complete: true,
|
complete: true,
|
||||||
zones: [{ snapshotKey: "unknown", name: "Unknown", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
|
zones: [],
|
||||||
|
}), /zone_source_authority_mismatch/);
|
||||||
|
assert.throws(() => normalizeZoneSourceGeneration({
|
||||||
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
adapterId: "depttrans-mmap-snapshot-v1",
|
||||||
|
contentDigest: snapshotDigest,
|
||||||
|
generatedAt,
|
||||||
|
complete: true,
|
||||||
|
zones: [{ snapshotKey: "unknown", displayName: "Unknown", geometry: snapshot.facts[0].geometry }],
|
||||||
}), /zone_source_identity_crosswalk_required/);
|
}), /zone_source_identity_crosswalk_required/);
|
||||||
assert.throws(() => normalizeZoneSourceGeneration({
|
assert.throws(() => normalizeZoneSourceGeneration({
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
adapterId: "mmap-snapshot-v1",
|
authorityId: "moscow-department-of-transport",
|
||||||
snapshotDigest,
|
datasetId: "pmd-slow-zones",
|
||||||
generatedAt,
|
adapterId: "depttrans-mmap-snapshot-v1",
|
||||||
complete: true,
|
contentDigest: snapshotDigest,
|
||||||
zones: [{ id: 42, name: "Uncrossed", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
|
|
||||||
}), /zone_source_identity_crosswalk_required/);
|
|
||||||
assert.throws(() => normalizeZoneSourceGeneration({
|
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
|
||||||
adapterId: "gelios-rest-v1",
|
|
||||||
generatedAt,
|
|
||||||
complete: true,
|
|
||||||
zones: [{ name: "No native id", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
|
|
||||||
}), /zone_source_native_identity_required/);
|
|
||||||
assert.throws(() => normalizeZoneSourceGeneration({
|
|
||||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
|
||||||
adapterId: "gelios-rest-v1",
|
|
||||||
generatedAt,
|
generatedAt,
|
||||||
complete: false,
|
complete: false,
|
||||||
zones: [],
|
zones: [],
|
||||||
}), /zone_source_generation_incomplete/);
|
}), /zone_source_generation_incomplete/);
|
||||||
|
|
||||||
|
const actualSource = await loadZoneSourceProfile(
|
||||||
|
fileURLToPath(new URL("../../../services/map-gateway/zone-sources", import.meta.url)),
|
||||||
|
"moscow-pmd-slow-zones",
|
||||||
|
);
|
||||||
|
const actual = normalizeZoneSourceGeneration(actualSource.generation);
|
||||||
|
assert.equal(actual.facts.length, 903);
|
||||||
|
assert.equal(actual.facts[0].sourceId.startsWith("depttrans-pmd-slow-zone-"), true);
|
||||||
|
assert.equal(actual.facts.every((fact) => fact.attributes.dataset_authority === "moscow-department-of-transport"), true);
|
||||||
|
assert.equal(actual.facts.every((fact) => fact.attributes.dataset_id === "pmd-slow-zones"), true);
|
||||||
|
assert.equal(actual.facts.every((fact) => fact.attributes.source_adapter === "depttrans-mmap-snapshot-v1"), true);
|
||||||
|
assert.equal(actual.facts.every((fact) => fact.attributes.weekly_speed_schedule.length === 7), true);
|
||||||
|
assert.equal(actual.facts.every((fact) => Number(fact.attributes.area_square_meters) > 0), true);
|
||||||
|
|
||||||
console.log("external-provider zone source: ok");
|
console.log("external-provider zone source: ok");
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,50 @@
|
||||||
{
|
{
|
||||||
"id": "map.zones.current.v1",
|
"id": "map.zones.current.v1",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"ontologyRevision": "ontology.map.zone.v1",
|
"ontologyRevision": "ontology.map.zone.v1",
|
||||||
"deliveryMode": "snapshot+patch",
|
"deliveryMode": "snapshot+patch",
|
||||||
"semanticTypes": ["map.zone"],
|
"semanticTypes": ["map.zone"],
|
||||||
"fields": [
|
"fields": [
|
||||||
"area_square_meters",
|
"area_square_meters",
|
||||||
|
"applies_to_couriers",
|
||||||
|
"applies_to_kicksharing",
|
||||||
|
"dataset_authority",
|
||||||
|
"dataset_id",
|
||||||
"description",
|
"description",
|
||||||
"display_name",
|
"display_name",
|
||||||
"geometry",
|
"geometry",
|
||||||
"geometry_kind",
|
"geometry_kind",
|
||||||
"max_speed_kph",
|
"max_speed_kph",
|
||||||
"perimeter_meters",
|
"perimeter_meters",
|
||||||
|
"schedule_timezone",
|
||||||
|
"source_adapter",
|
||||||
|
"source_created_at",
|
||||||
"source_kind",
|
"source_kind",
|
||||||
"source_revision",
|
"source_revision",
|
||||||
"style_color"
|
"source_updated_at",
|
||||||
|
"style_color",
|
||||||
|
"weekly_speed_schedule"
|
||||||
],
|
],
|
||||||
"fieldContracts": {
|
"fieldContracts": {
|
||||||
"area_square_meters": { "type": "number", "required": false, "minimum": 0 },
|
"area_square_meters": { "type": "number", "required": false, "minimum": 0 },
|
||||||
|
"applies_to_couriers": { "type": "boolean", "required": false },
|
||||||
|
"applies_to_kicksharing": { "type": "boolean", "required": false },
|
||||||
|
"dataset_authority": { "type": "string", "required": true, "enum": ["moscow-department-of-transport"] },
|
||||||
|
"dataset_id": { "type": "string", "required": true, "enum": ["pmd-slow-zones"] },
|
||||||
"description": { "type": "string", "required": false },
|
"description": { "type": "string", "required": false },
|
||||||
"display_name": { "type": "string", "required": true },
|
"display_name": { "type": "string", "required": true },
|
||||||
"geometry": { "type": "geometry", "required": true },
|
"geometry": { "type": "geometry", "required": true },
|
||||||
"geometry_kind": { "type": "string", "required": true, "enum": ["circle", "corridor", "polygon"] },
|
"geometry_kind": { "type": "string", "required": true, "enum": ["circle", "corridor", "polygon"] },
|
||||||
"max_speed_kph": { "type": "number", "required": false, "minimum": 0 },
|
"max_speed_kph": { "type": "number", "required": false, "minimum": 0 },
|
||||||
"perimeter_meters": { "type": "number", "required": false, "minimum": 0 },
|
"perimeter_meters": { "type": "number", "required": false, "minimum": 0 },
|
||||||
"source_kind": { "type": "string", "required": true, "enum": ["live_api", "versioned_snapshot"] },
|
"schedule_timezone": { "type": "string", "required": false, "enum": ["Europe/Moscow"] },
|
||||||
|
"source_adapter": { "type": "string", "required": true, "enum": ["depttrans-mmap-snapshot-v1", "depttrans-api-snapshot-v1"] },
|
||||||
|
"source_created_at": { "type": "string", "required": false },
|
||||||
|
"source_kind": { "type": "string", "required": true, "enum": ["versioned_snapshot"] },
|
||||||
"source_revision": { "type": "string", "required": true },
|
"source_revision": { "type": "string", "required": true },
|
||||||
"style_color": { "type": "string", "required": false }
|
"source_updated_at": { "type": "string", "required": false },
|
||||||
|
"style_color": { "type": "string", "required": false },
|
||||||
|
"weekly_speed_schedule": { "type": "string_array", "required": false }
|
||||||
},
|
},
|
||||||
"history": { "mode": "none", "retentionDays": 1 }
|
"history": { "mode": "none", "retentionDays": 1 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@ assert.deepEqual(bundled[3].fieldContracts.movement_state, {
|
||||||
required: true,
|
required: true,
|
||||||
enum: ["moving", "stopped"],
|
enum: ["moving", "stopped"],
|
||||||
});
|
});
|
||||||
assert.equal(bundled[4].version, "1.0.0");
|
assert.equal(bundled[4].version, "1.1.0");
|
||||||
assert.equal(bundled[4].ontologyRevision, "ontology.map.zone.v1");
|
assert.equal(bundled[4].ontologyRevision, "ontology.map.zone.v1");
|
||||||
assert.deepEqual(bundled[4].semanticTypes, ["map.zone"]);
|
assert.deepEqual(bundled[4].semanticTypes, ["map.zone"]);
|
||||||
assert.deepEqual(bundled[4].fieldContracts.geometry, { type: "geometry", required: true });
|
assert.deepEqual(bundled[4].fieldContracts.geometry, { type: "geometry", required: true });
|
||||||
assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length);
|
assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length);
|
||||||
|
assert.equal(Object.keys(bundled[4].fieldContracts).length, bundled[4].fields.length);
|
||||||
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
|
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ RUN apk add --no-cache su-exec
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY zone-sources ./zone-sources
|
||||||
COPY runtime-entrypoint.mjs /app/runtime-entrypoint.mjs
|
COPY runtime-entrypoint.mjs /app/runtime-entrypoint.mjs
|
||||||
|
|
||||||
EXPOSE 18103
|
EXPOSE 18103
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
- при upstream outage отдаёт ранее сохранённый cache object; offline режим включается только для providers, явно разрешённых их лицензией;
|
- при upstream outage отдаёт ранее сохранённый cache object; offline режим включается только для providers, явно разрешённых их лицензией;
|
||||||
- защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key;
|
- защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key;
|
||||||
- ведёт лёгкий persistent cache index и health/stats без автоматического удаления уже записанных tiles.
|
- ведёт лёгкий persistent cache index и health/stats без автоматического удаления уже записанных tiles.
|
||||||
|
- отдаёт Engine отдельный provider-neutral generation геозон из проверенного профиля: текущий backend — версионированный snapshot Дептранса, будущий API-адаптер обязан материализовать тот же envelope и не меняет L2-потребителя.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
@ -21,6 +22,8 @@ GET /api/map/ion/assets/:assetId/endpoint
|
||||||
GET|HEAD /api/map/cache?url=<encoded-upstream-url>
|
GET|HEAD /api/map/cache?url=<encoded-upstream-url>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Внутренний route `GET|HEAD /internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current` доступен сервисам в private `engine` network и намеренно не публикуется через Caddy. На старте Gateway проверяет manifest, SHA-256 обоих исходных JSON, геометрию, замкнутость колец, уникальность identity, расписание и его связи с зонами; повреждённый или неполный generation останавливает запуск. Сейчас profile упакован в image как immutable MMap snapshot. Для будущего Дептранс API меняется producer профиля (`depttrans-api-snapshot-v1`), но endpoint, stable source IDs и `nodedc.zone-source-generation/v2` остаются прежними.
|
||||||
|
|
||||||
Внутренний admin route `GET|PUT /api/map/admin/cesium-ion` не является Browser API: он доступен только из Foundry по HMAC. Ключ подписи создаётся и хранится root-owned deploy runner в `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`, а оба сервиса получают только read-only file mount. Он не является `.env`-значением, настройкой Foundry или Cesium credential. Перед записью `PUT` проверяет candidate token через Cesium asset endpoints `1` (terrain), `2` (imagery) и `96188` (3D Buildings). Не прошедшее проверку значение не заменяет рабочее. Успешный token записывается атомарно в `${MAP_CACHE_DIR}/secrets/cesium-ion-token` с mode `0600`; ответ содержит только факт конфигурации, safe verification state и audit metadata — никогда само значение.
|
Внутренний admin route `GET|PUT /api/map/admin/cesium-ion` не является Browser API: он доступен только из Foundry по HMAC. Ключ подписи создаётся и хранится root-owned deploy runner в `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`, а оба сервиса получают только read-only file mount. Он не является `.env`-значением, настройкой Foundry или Cesium credential. Перед записью `PUT` проверяет candidate token через Cesium asset endpoints `1` (terrain), `2` (imagery) и `96188` (3D Buildings). Не прошедшее проверку значение не заменяет рабочее. Успешный token записывается атомарно в `${MAP_CACHE_DIR}/secrets/cesium-ion-token` с mode `0600`; ответ содержит только факт конфигурации, safe verification state и audit metadata — никогда само значение.
|
||||||
|
|
||||||
`/api/map/ion/assets/:assetId/endpoint` разрешает только `CESIUM_ION_ASSET_ALLOWLIST`. Ответ не содержит credential: Browser использует публичный provider URL вместе с Cesium `DefaultProxy`, направляющим resource requests в `/api/map/cache`. Gateway валидирует scope URL, удаляет любые credential query parameters из browser request и добавляет соответствующий server-side credential только перед обращением к provider.
|
`/api/map/ion/assets/:assetId/endpoint` разрешает только `CESIUM_ION_ASSET_ALLOWLIST`. Ответ не содержит credential: Browser использует публичный provider URL вместе с Cesium `DefaultProxy`, направляющим resource requests в `/api/map/cache`. Gateway валидирует scope URL, удаляет любые credential query parameters из browser request и добавляет соответствующий server-side credential только перед обращением к provider.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"test:admin-token-boundary": "node scripts/smoke-admin-boundary.mjs",
|
"test:admin-token-boundary": "node scripts/smoke-admin-boundary.mjs",
|
||||||
"test:live-cache-fallback": "node scripts/smoke-live-cache-fallback.mjs",
|
"test:live-cache-fallback": "node scripts/smoke-live-cache-fallback.mjs",
|
||||||
"test:streaming-cache-fill": "node scripts/smoke-streaming-cache-fill.mjs",
|
"test:streaming-cache-fill": "node scripts/smoke-streaming-cache-fill.mjs",
|
||||||
|
"test:zone-source": "node --test test/zone-source-snapshot.test.mjs",
|
||||||
|
"test:zone-source-route": "node scripts/smoke-zone-source.mjs",
|
||||||
"audit:engine-cache": "node scripts/audit-engine-cache.mjs",
|
"audit:engine-cache": "node scripts/audit-engine-cache.mjs",
|
||||||
"import:engine-cache": "node scripts/import-engine-cache.mjs"
|
"import:engine-cache": "node scripts/import-engine-cache.mjs"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-zone-source-smoke-"));
|
||||||
|
const port = await freePort();
|
||||||
|
let child;
|
||||||
|
|
||||||
|
try {
|
||||||
|
child = spawn(process.execPath, ["src/server.mjs"], {
|
||||||
|
cwd: new URL("..", import.meta.url),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(port),
|
||||||
|
MAP_CACHE_DIR: join(root, "cache"),
|
||||||
|
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
|
||||||
|
MAP_CACHE_MODE: "readwrite",
|
||||||
|
CESIUM_ION_TOKEN: "",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForGateway(port, child);
|
||||||
|
const route = `http://127.0.0.1:${port}/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current`;
|
||||||
|
const response = await fetch(route);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get("etag") || "", /^"sha256:[a-f0-9]{64}"$/);
|
||||||
|
const generation = await response.json();
|
||||||
|
assert.equal(generation.schemaVersion, "nodedc.zone-source-generation/v2");
|
||||||
|
assert.equal(generation.authorityId, "moscow-department-of-transport");
|
||||||
|
assert.equal(generation.datasetId, "pmd-slow-zones");
|
||||||
|
assert.equal(generation.complete, true);
|
||||||
|
assert.equal(generation.zones.length, 903);
|
||||||
|
assert.equal(generation.metadata.scheduleRowCount, 6335);
|
||||||
|
|
||||||
|
const head = await fetch(route, { method: "HEAD" });
|
||||||
|
assert.equal(head.status, 200);
|
||||||
|
assert.equal(await head.text(), "");
|
||||||
|
const cached = await fetch(route, { headers: { "If-None-Match": response.headers.get("etag") } });
|
||||||
|
assert.equal(cached.status, 304);
|
||||||
|
console.log("ok: immutable Department zone source is served with stable digest and cache semantics");
|
||||||
|
} finally {
|
||||||
|
if (child && !child.killed) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await once(child, "exit").catch(() => undefined);
|
||||||
|
}
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const server = createServer();
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
assert(address && typeof address === "object");
|
||||||
|
const selected = address.port;
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForGateway(selectedPort, processHandle) {
|
||||||
|
let output = "";
|
||||||
|
processHandle.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||||
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||||
|
if (processHandle.exitCode !== null) throw new Error(`gateway_exited:${processHandle.exitCode}:${output}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${selectedPort}/healthz`);
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch { /* service is still starting */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`gateway_start_timeout:${output}`);
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,12 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import { createReadStream, createWriteStream } from "node:fs";
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { Readable, Transform } from "node:stream";
|
import { Readable, Transform } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs";
|
||||||
|
|
||||||
const canonicalCesiumAssetIds = ["1", "2", "96188"];
|
const canonicalCesiumAssetIds = ["1", "2", "96188"];
|
||||||
const cesiumVerificationTransportVersion = 4;
|
const cesiumVerificationTransportVersion = 4;
|
||||||
|
|
@ -20,6 +23,15 @@ const canonicalCesiumEgressHosts = new Set([
|
||||||
"ecn.t3.tiles.virtualearth.net",
|
"ecn.t3.tiles.virtualearth.net",
|
||||||
]);
|
]);
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
|
const zoneSourceProfileId = String(process.env.ZONE_SOURCE_PROFILE_ID || "moscow-pmd-slow-zones").trim();
|
||||||
|
const zoneSourceRoot = resolve(String(
|
||||||
|
process.env.ZONE_SOURCE_SNAPSHOT_ROOT
|
||||||
|
|| resolve(dirname(fileURLToPath(import.meta.url)), "../zone-sources"),
|
||||||
|
).trim());
|
||||||
|
// The route contract is stable while the provider behind this profile is
|
||||||
|
// replaceable: today it is the audited MMap snapshot, later an API adapter can
|
||||||
|
// atomically materialise the same manifest + generation envelope.
|
||||||
|
const zoneSource = await loadZoneSourceProfile(zoneSourceRoot, zoneSourceProfileId);
|
||||||
const liveCache = createCacheStore("live", config.cacheDir, true);
|
const liveCache = createCacheStore("live", config.cacheDir, true);
|
||||||
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
|
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
|
||||||
const inflightWrites = new Map();
|
const inflightWrites = new Map();
|
||||||
|
|
@ -49,6 +61,13 @@ const server = createServer(async (request, response) => {
|
||||||
if (requestUrl.pathname === "/api/map/admin/cesium-ion" && ["GET", "PUT"].includes(request.method || "")) {
|
if (requestUrl.pathname === "/api/map/admin/cesium-ion" && ["GET", "PUT"].includes(request.method || "")) {
|
||||||
return await serveCesiumIonAdmin(request, response, requestUrl.pathname);
|
return await serveCesiumIonAdmin(request, response, requestUrl.pathname);
|
||||||
}
|
}
|
||||||
|
if (requestUrl.pathname === `/internal/zone-sources/v1/profiles/${encodeURIComponent(zoneSourceProfileId)}/current`) {
|
||||||
|
if (!["GET", "HEAD"].includes(request.method || "")) {
|
||||||
|
response.setHeader("Allow", "GET, HEAD");
|
||||||
|
return writeJson(response, 405, { ok: false, error: "zone_source_method_not_allowed" });
|
||||||
|
}
|
||||||
|
return serveZoneSourceGeneration(request, response);
|
||||||
|
}
|
||||||
if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" });
|
if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" });
|
||||||
|
|
||||||
if (requestUrl.pathname === "/healthz" && request.method === "GET") {
|
if (requestUrl.pathname === "/healthz" && request.method === "GET") {
|
||||||
|
|
@ -61,6 +80,15 @@ const server = createServer(async (request, response) => {
|
||||||
ionConfigured: Boolean(activeCesiumIonToken),
|
ionConfigured: Boolean(activeCesiumIonToken),
|
||||||
assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right),
|
assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right),
|
||||||
anonymousAccess: config.allowAnonymous,
|
anonymousAccess: config.allowAnonymous,
|
||||||
|
zoneSource: {
|
||||||
|
profileId: zoneSourceProfileId,
|
||||||
|
authorityId: zoneSource.generation.authorityId,
|
||||||
|
datasetId: zoneSource.generation.datasetId,
|
||||||
|
sourceRevision: zoneSource.generation.sourceRevision,
|
||||||
|
contentDigest: zoneSource.generation.contentDigest,
|
||||||
|
sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount,
|
||||||
|
publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,11 +125,27 @@ server.listen(config.port, "0.0.0.0", () => {
|
||||||
console.log(`NODE.DC Map Gateway listening on http://0.0.0.0:${config.port}`);
|
console.log(`NODE.DC Map Gateway listening on http://0.0.0.0:${config.port}`);
|
||||||
console.log(`Live map cache: ${config.cacheDir} (${config.mode}, max ${Math.round(config.maxCacheBytes / 1024 / 1024)} MB)`);
|
console.log(`Live map cache: ${config.cacheDir} (${config.mode}, max ${Math.round(config.maxCacheBytes / 1024 / 1024)} MB)`);
|
||||||
if (offlineSnapshot) console.log(`Offline map snapshot: ${config.offlineSnapshotDir} (read-only)`);
|
if (offlineSnapshot) console.log(`Offline map snapshot: ${config.offlineSnapshotDir} (read-only)`);
|
||||||
|
console.log(`Zone source: ${zoneSourceProfileId}@${zoneSource.generation.sourceRevision} (${zoneSource.generation.metadata.publishedZoneCount} zones)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => server.close());
|
process.on("SIGTERM", () => server.close());
|
||||||
process.on("SIGINT", () => server.close());
|
process.on("SIGINT", () => server.close());
|
||||||
|
|
||||||
|
function serveZoneSourceGeneration(request, response) {
|
||||||
|
if (request.headers["if-none-match"] === zoneSource.etag) {
|
||||||
|
response.writeHead(304, { ETag: zoneSource.etag, "Cache-Control": "private, max-age=60" });
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Length": zoneSource.body.byteLength,
|
||||||
|
"Cache-Control": "private, max-age=60",
|
||||||
|
ETag: zoneSource.etag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
});
|
||||||
|
return response.end(request.method === "HEAD" ? undefined : zoneSource.body);
|
||||||
|
}
|
||||||
|
|
||||||
async function readConfig() {
|
async function readConfig() {
|
||||||
const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase();
|
const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase();
|
||||||
if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode");
|
if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
|
||||||
|
export const ZONE_SOURCE_MANIFEST_SCHEMA_VERSION = "nodedc.zone-source-manifest/v1";
|
||||||
|
|
||||||
|
const PROFILE_ID = /^[a-z][a-z0-9._-]{2,95}$/;
|
||||||
|
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
|
||||||
|
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
||||||
|
const MAX_ZONES = 5000;
|
||||||
|
const MAX_SCHEDULE_ROWS = 50_000;
|
||||||
|
const MAX_POSITIONS = 250_000;
|
||||||
|
const PROFILE_POLICIES = Object.freeze({
|
||||||
|
"moscow-pmd-slow-zones": Object.freeze({
|
||||||
|
authorityId: "moscow-department-of-transport",
|
||||||
|
datasetId: "pmd-slow-zones",
|
||||||
|
adapterIds: Object.freeze(["depttrans-mmap-snapshot-v1", "depttrans-api-snapshot-v1"]),
|
||||||
|
timezone: "Europe/Moscow",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadZoneSourceProfile(root, profileId) {
|
||||||
|
if (!PROFILE_ID.test(String(profileId || ""))) throw snapshotError("zone_source_profile_id_invalid");
|
||||||
|
const profileRoot = withinRoot(root, profileId);
|
||||||
|
const manifest = parseJson(
|
||||||
|
await readBounded(withinRoot(profileRoot, "manifest.json"), 256 * 1024),
|
||||||
|
"zone_source_manifest_json_invalid",
|
||||||
|
);
|
||||||
|
validateManifest(manifest, profileId);
|
||||||
|
|
||||||
|
const geometryPath = withinRoot(profileRoot, manifest.geometry.file);
|
||||||
|
const schedulePath = withinRoot(profileRoot, manifest.schedule.file);
|
||||||
|
const [geometryBytes, scheduleBytes] = await Promise.all([
|
||||||
|
readBounded(geometryPath, MAX_SOURCE_BYTES),
|
||||||
|
readBounded(schedulePath, MAX_SOURCE_BYTES),
|
||||||
|
]);
|
||||||
|
if (geometryBytes.byteLength + scheduleBytes.byteLength > MAX_SOURCE_BYTES) {
|
||||||
|
throw snapshotError("zone_source_snapshot_bytes_exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryDigest = sha256(geometryBytes);
|
||||||
|
const scheduleDigest = sha256(scheduleBytes);
|
||||||
|
if (geometryDigest !== manifest.geometry.sha256) throw snapshotError("zone_source_geometry_digest_mismatch");
|
||||||
|
if (scheduleDigest !== manifest.schedule.sha256) throw snapshotError("zone_source_schedule_digest_mismatch");
|
||||||
|
|
||||||
|
const geojson = parseJson(geometryBytes, "zone_source_geojson_invalid");
|
||||||
|
const rawSchedule = parseJson(scheduleBytes, "zone_source_schedule_json_invalid");
|
||||||
|
const generation = normalizeGeneration(manifest, geojson, rawSchedule, {
|
||||||
|
geometryDigest,
|
||||||
|
scheduleDigest,
|
||||||
|
sourceBytes: geometryBytes.byteLength + scheduleBytes.byteLength,
|
||||||
|
});
|
||||||
|
const body = Buffer.from(`${JSON.stringify(generation)}\n`);
|
||||||
|
if (body.byteLength > MAX_SOURCE_BYTES) throw snapshotError("zone_source_generation_bytes_exceeded");
|
||||||
|
return Object.freeze({ generation: deepFreeze(generation), body, etag: `"sha256:${generation.contentDigest}"` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeneration(manifest, geojson, rawSchedule, digests) {
|
||||||
|
if (!geojson || geojson.type !== "FeatureCollection" || !Array.isArray(geojson.features)) {
|
||||||
|
throw snapshotError("zone_source_feature_collection_required");
|
||||||
|
}
|
||||||
|
if (!geojson.features.length || geojson.features.length > MAX_ZONES) {
|
||||||
|
throw snapshotError("zone_source_zone_limit_exceeded");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rawSchedule) || !rawSchedule.length || rawSchedule.length > MAX_SCHEDULE_ROWS) {
|
||||||
|
throw snapshotError("zone_source_schedule_limit_exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = new Map();
|
||||||
|
let positionCount = 0;
|
||||||
|
for (const [index, feature] of geojson.features.entries()) {
|
||||||
|
if (!feature || feature.type !== "Feature" || !isPlainObject(feature.properties)) {
|
||||||
|
throw snapshotError(`zone_source_feature_${index}_invalid`);
|
||||||
|
}
|
||||||
|
const nativeId = nativeZoneId(feature.properties.pmd_slow_zone_id);
|
||||||
|
if (features.has(nativeId)) throw snapshotError("zone_source_identity_duplicate");
|
||||||
|
positionCount += validateGeometry(feature.geometry, index);
|
||||||
|
if (positionCount > MAX_POSITIONS) throw snapshotError("zone_source_position_limit_exceeded");
|
||||||
|
features.set(nativeId, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleByZone = new Map();
|
||||||
|
const scheduleIds = new Set();
|
||||||
|
const scheduleKeys = new Set();
|
||||||
|
for (const [index, row] of rawSchedule.entries()) {
|
||||||
|
if (!isPlainObject(row)) throw snapshotError(`zone_source_schedule_${index}_invalid`);
|
||||||
|
const rowId = nativeScheduleId(row.id);
|
||||||
|
if (scheduleIds.has(rowId)) throw snapshotError("zone_source_schedule_identity_duplicate");
|
||||||
|
scheduleIds.add(rowId);
|
||||||
|
const zoneId = nativeZoneId(row.pmd_slow_zone_id);
|
||||||
|
if (!features.has(zoneId)) throw snapshotError("zone_source_schedule_zone_missing");
|
||||||
|
const dayOfWeek = integer(row.day_of_week_num, 1, 7, "zone_source_schedule_day_invalid");
|
||||||
|
const speedLimitKph = finite(row.speed_limit, 0, 200, "zone_source_schedule_speed_invalid");
|
||||||
|
const startTime = clock(row.start_time, "zone_source_schedule_start_invalid");
|
||||||
|
const endTime = clock(row.end_time, "zone_source_schedule_end_invalid");
|
||||||
|
const key = `${zoneId}\u0000${dayOfWeek}\u0000${startTime}\u0000${endTime}`;
|
||||||
|
if (scheduleKeys.has(key)) throw snapshotError("zone_source_schedule_interval_duplicate");
|
||||||
|
scheduleKeys.add(key);
|
||||||
|
const normalized = Object.freeze({ dayOfWeek, startTime, endTime, speedLimitKph });
|
||||||
|
const entries = scheduleByZone.get(zoneId) || [];
|
||||||
|
entries.push(normalized);
|
||||||
|
scheduleByZone.set(zoneId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zones = [];
|
||||||
|
let excludedZoneCount = 0;
|
||||||
|
for (const [nativeId, feature] of features) {
|
||||||
|
const properties = feature.properties;
|
||||||
|
if (properties.archive_flg === true || properties.delete_flg === true) {
|
||||||
|
excludedZoneCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const schedule = [...(scheduleByZone.get(nativeId) || [])].sort(compareSchedule);
|
||||||
|
if (!schedule.length || new Set(schedule.map((entry) => entry.dayOfWeek)).size !== 7) {
|
||||||
|
throw snapshotError("zone_source_schedule_week_incomplete");
|
||||||
|
}
|
||||||
|
const speeds = [...new Set(schedule.map((entry) => entry.speedLimitKph))];
|
||||||
|
zones.push(compact({
|
||||||
|
nativeId,
|
||||||
|
displayName: requiredString(properties.pmd_slow_zone_nm, "zone_source_name_required"),
|
||||||
|
geometry: structuredClone(feature.geometry),
|
||||||
|
areaSquareMeters: optionalFinite(properties.area, 0, Number.MAX_SAFE_INTEGER),
|
||||||
|
description: optionalString(properties.comment),
|
||||||
|
appliesToKicksharing: boolean(properties.kick_sharing, "zone_source_kicksharing_flag_invalid"),
|
||||||
|
appliesToCouriers: boolean(properties.courier, "zone_source_courier_flag_invalid"),
|
||||||
|
sourceCreatedAt: optionalString(properties.create_dttm),
|
||||||
|
sourceUpdatedAt: optionalString(properties.update_dttm),
|
||||||
|
maxSpeedKph: speeds.length === 1 ? speeds[0] : undefined,
|
||||||
|
weeklySpeedSchedule: schedule,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
zones.sort((left, right) => compareNativeIds(left.nativeId, right.nativeId));
|
||||||
|
|
||||||
|
const contentDigest = sha256(Buffer.from(`${digests.geometryDigest}\n${digests.scheduleDigest}\n`));
|
||||||
|
return {
|
||||||
|
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||||
|
profileId: manifest.profileId,
|
||||||
|
authorityId: manifest.authorityId,
|
||||||
|
datasetId: manifest.datasetId,
|
||||||
|
adapterId: manifest.adapterId,
|
||||||
|
sourceRevision: manifest.sourceRevision,
|
||||||
|
generatedAt: iso(manifest.generatedAt, "zone_source_generated_at_invalid"),
|
||||||
|
timezone: manifest.timezone,
|
||||||
|
complete: true,
|
||||||
|
contentDigest,
|
||||||
|
zones,
|
||||||
|
metadata: {
|
||||||
|
sourceZoneCount: features.size,
|
||||||
|
publishedZoneCount: zones.length,
|
||||||
|
excludedZoneCount,
|
||||||
|
scheduleRowCount: rawSchedule.length,
|
||||||
|
positionCount,
|
||||||
|
sourceBytes: digests.sourceBytes,
|
||||||
|
geometrySha256: digests.geometryDigest,
|
||||||
|
scheduleSha256: digests.scheduleDigest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(value, requestedProfileId) {
|
||||||
|
if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_MANIFEST_SCHEMA_VERSION) {
|
||||||
|
throw snapshotError("zone_source_manifest_schema_invalid");
|
||||||
|
}
|
||||||
|
if (value.profileId !== requestedProfileId || !PROFILE_ID.test(value.profileId)) {
|
||||||
|
throw snapshotError("zone_source_manifest_profile_mismatch");
|
||||||
|
}
|
||||||
|
const policy = PROFILE_POLICIES[requestedProfileId];
|
||||||
|
if (!policy) throw snapshotError("zone_source_profile_unsupported");
|
||||||
|
for (const key of ["authorityId", "datasetId", "adapterId"]) {
|
||||||
|
if (!IDENTIFIER.test(String(value[key] || ""))) throw snapshotError(`zone_source_manifest_${key}_invalid`);
|
||||||
|
}
|
||||||
|
if (value.authorityId !== policy.authorityId) throw snapshotError("zone_source_manifest_authority_mismatch");
|
||||||
|
if (value.datasetId !== policy.datasetId) throw snapshotError("zone_source_manifest_dataset_mismatch");
|
||||||
|
if (!policy.adapterIds.includes(value.adapterId)) throw snapshotError("zone_source_manifest_adapter_unsupported");
|
||||||
|
requiredString(value.sourceRevision, "zone_source_manifest_revision_invalid");
|
||||||
|
iso(value.generatedAt, "zone_source_manifest_generated_at_invalid");
|
||||||
|
if (value.timezone !== policy.timezone) throw snapshotError("zone_source_manifest_timezone_invalid");
|
||||||
|
for (const [name, artifact] of [["geometry", value.geometry], ["schedule", value.schedule]]) {
|
||||||
|
if (!isPlainObject(artifact) || !safeFileName(artifact.file) || !SHA256.test(String(artifact.sha256 || ""))) {
|
||||||
|
throw snapshotError(`zone_source_manifest_${name}_invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGeometry(value, index) {
|
||||||
|
if (!isPlainObject(value) || !new Set(["Polygon", "MultiPolygon"]).has(value.type)) {
|
||||||
|
throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||||
|
}
|
||||||
|
const polygons = value.type === "Polygon" ? [value.coordinates] : value.coordinates;
|
||||||
|
if (!Array.isArray(polygons) || !polygons.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||||
|
let positions = 0;
|
||||||
|
for (const polygon of polygons) {
|
||||||
|
if (!Array.isArray(polygon) || !polygon.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||||
|
for (const ring of polygon) {
|
||||||
|
if (!Array.isArray(ring) || ring.length < 4) throw snapshotError(`zone_source_feature_${index}_ring_invalid`);
|
||||||
|
for (const point of ring) {
|
||||||
|
if (!Array.isArray(point) || point.length < 2) throw snapshotError(`zone_source_feature_${index}_position_invalid`);
|
||||||
|
const longitude = Number(point[0]);
|
||||||
|
const latitude = Number(point[1]);
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)
|
||||||
|
|| longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||||
|
throw snapshotError(`zone_source_feature_${index}_position_invalid`);
|
||||||
|
}
|
||||||
|
positions += 1;
|
||||||
|
}
|
||||||
|
const first = ring[0];
|
||||||
|
const last = ring.at(-1);
|
||||||
|
if (first[0] !== last[0] || first[1] !== last[1]) throw snapshotError(`zone_source_feature_${index}_ring_unclosed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSchedule(left, right) {
|
||||||
|
return left.dayOfWeek - right.dayOfWeek
|
||||||
|
|| left.startTime.localeCompare(right.startTime)
|
||||||
|
|| left.endTime.localeCompare(right.endTime)
|
||||||
|
|| left.speedLimitKph - right.speedLimitKph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNativeIds(left, right) {
|
||||||
|
const a = Number(left);
|
||||||
|
const b = Number(right);
|
||||||
|
return Number.isSafeInteger(a) && Number.isSafeInteger(b) ? a - b : left.localeCompare(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeZoneId(value) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_native_identity_invalid");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeScheduleId(value) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_schedule_identity_invalid");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBounded(path, maxBytes) {
|
||||||
|
const value = await readFile(path);
|
||||||
|
if (value.byteLength > maxBytes) throw snapshotError("zone_source_file_bytes_exceeded");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withinRoot(root, child) {
|
||||||
|
const base = resolve(root);
|
||||||
|
const target = resolve(base, child);
|
||||||
|
if (target !== base && !target.startsWith(`${base}${sep}`)) throw snapshotError("zone_source_path_escape_rejected");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileName(value) {
|
||||||
|
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, code) {
|
||||||
|
try { return JSON.parse(value.toString("utf8")); } catch { throw snapshotError(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value, min, max, code) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isInteger(number) || number < min || number > max) throw snapshotError(code);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finite(value, min, max, code) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number) || number < min || number > max) throw snapshotError(code);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalFinite(value, min, max) {
|
||||||
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number >= min && number <= max ? number : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value, code) {
|
||||||
|
if (typeof value !== "boolean") throw snapshotError(code);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clock(value, code) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!TIME.test(normalized)) throw snapshotError(code);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value, code) {
|
||||||
|
const date = new Date(String(value ?? ""));
|
||||||
|
if (Number.isNaN(date.getTime())) throw snapshotError(code);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value, code) {
|
||||||
|
const normalized = optionalString(value);
|
||||||
|
if (!normalized) throw snapshotError(code);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value) {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFreeze(value) {
|
||||||
|
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||||
|
Object.freeze(value);
|
||||||
|
Object.values(value).forEach(deepFreeze);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotError(code) {
|
||||||
|
return Object.assign(new Error(code), { code });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { loadZoneSourceProfile, ZONE_SOURCE_GENERATION_SCHEMA_VERSION } from "../src/zone-source-snapshot.mjs";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../zone-sources");
|
||||||
|
|
||||||
|
test("loads the immutable Department of Transport MMap generation", async () => {
|
||||||
|
const loaded = await loadZoneSourceProfile(root, "moscow-pmd-slow-zones");
|
||||||
|
const generation = loaded.generation;
|
||||||
|
|
||||||
|
assert.equal(generation.schemaVersion, ZONE_SOURCE_GENERATION_SCHEMA_VERSION);
|
||||||
|
assert.equal(generation.authorityId, "moscow-department-of-transport");
|
||||||
|
assert.equal(generation.datasetId, "pmd-slow-zones");
|
||||||
|
assert.equal(generation.adapterId, "depttrans-mmap-snapshot-v1");
|
||||||
|
assert.equal(generation.complete, true);
|
||||||
|
assert.equal(generation.metadata.sourceZoneCount, 905);
|
||||||
|
assert.equal(generation.metadata.publishedZoneCount, 903);
|
||||||
|
assert.equal(generation.metadata.excludedZoneCount, 2);
|
||||||
|
assert.equal(generation.metadata.scheduleRowCount, 6335);
|
||||||
|
assert.equal(generation.metadata.positionCount, 62955);
|
||||||
|
assert.equal(generation.metadata.geometrySha256, "914797122297d06acaff8a32605f172abb6c769b879d1b75f567db450d29913f");
|
||||||
|
assert.equal(generation.metadata.scheduleSha256, "8f3e50e2aec3889657814ea17982b2e75fcc2b83d0511c901016cd08e5844cd5");
|
||||||
|
assert.match(generation.contentDigest, /^[a-f0-9]{64}$/);
|
||||||
|
assert.ok(loaded.body.byteLength < 16 * 1024 * 1024);
|
||||||
|
assert.equal(generation.zones.some((zone) => zone.nativeId === "1521" || zone.nativeId === "1522"), false);
|
||||||
|
|
||||||
|
const zone = generation.zones.find((entry) => entry.nativeId === "1478");
|
||||||
|
assert.equal(zone.displayName, "Петровский парк_6 (15 км/ч)");
|
||||||
|
assert.equal(zone.geometry.type, "Polygon");
|
||||||
|
assert.equal(zone.maxSpeedKph, 15);
|
||||||
|
assert.equal(zone.weeklySpeedSchedule.length, 7);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
pmd_slow_zone_*.json -diff -text
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.zone-source-manifest/v1",
|
||||||
|
"profileId": "moscow-pmd-slow-zones",
|
||||||
|
"authorityId": "moscow-department-of-transport",
|
||||||
|
"datasetId": "pmd-slow-zones",
|
||||||
|
"adapterId": "depttrans-mmap-snapshot-v1",
|
||||||
|
"sourceRevision": "pmd-slow-zones-202506031405",
|
||||||
|
"generatedAt": "2025-06-03T11:05:00.000Z",
|
||||||
|
"timezone": "Europe/Moscow",
|
||||||
|
"geometry": {
|
||||||
|
"file": "pmd_slow_zone_geojson_202506031405.json",
|
||||||
|
"sha256": "914797122297d06acaff8a32605f172abb6c769b879d1b75f567db450d29913f"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"file": "pmd_slow_zone_schedule_202506031405.json",
|
||||||
|
"sha256": "8f3e50e2aec3889657814ea17982b2e75fcc2b83d0511c901016cd08e5844cd5"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue