diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..5710dae --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Summary + + + +## Community mesh submissions + + + +- [ ] I copied `data/communityMeshes/_template.json` and used a stable unique ID. +- [ ] The GeoJSON coverage uses `[longitude, latitude]` and has closed rings. +- [ ] The listed contacts and radio settings are maintained by this community. +- [ ] Any published PSKs or MQTT credentials are intentionally public. +- [ ] Licensed profiles have null PSKs and unlicensed profiles have valid PSKs. +- [ ] I ran `pnpm validate:community-meshes`, `pnpm test`, and `pnpm build`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bee7f1..92791c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,11 @@ jobs: - name: Install Dependencies run: pnpm install --ignore-scripts=false + - name: Validate Community Mesh Registry + run: pnpm validate:community-meshes + + - name: Run Tests + run: pnpm test + - name: Build Package run: pnpm build diff --git a/README.md b/README.md index 53f99b5..403ec48 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ API backend used for meshtastic.org and associated tools. +## Community Mesh Registry + +Clients can download the community mesh registry from `GET /v1/community-meshes`, +inspect one record at `GET /v1/community-meshes/:id`, and fetch its JSON Schema +from `GET /v1/community-meshes/schema`. Discovery is privacy-preserving: clients +download the same cacheable registry and compare its GeoJSON coverage locally; +they do not send a location to this API. + +To list or update a community, follow the +[community mesh contribution guide](docs/community-mesh-registry.md). + ## Stats ![Alt](https://repobeats.axiom.co/api/embed/42de1569e61bc8171266ba5e0cc1dab2e0a3986b.svg "Repobeats analytics image") diff --git a/data/communityMeshes/_template.json b/data/communityMeshes/_template.json new file mode 100644 index 0000000..4ec5efd --- /dev/null +++ b/data/communityMeshes/_template.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "id": "replace-with-community-id", + "name": "Replace with community name", + "description": "Replace with a clear public description.", + "coverage": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] + }, + "links": [{ "kind": "website", "url": "https://example.org" }], + "meshProfiles": [ + { + "id": "public", + "name": "Public", + "description": "Replace with profile description.", + "radio": { + "region": "US", + "modem": { "kind": "preset", "preset": "LONG_FAST" }, + "frequencySlot": 0, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Public", + "pskBase64": "AQ==", + "uplinkEnabled": false, + "downlinkEnabled": false + } + } + ] +} diff --git a/docs/community-mesh-registry.md b/docs/community-mesh-registry.md new file mode 100644 index 0000000..0a95360 --- /dev/null +++ b/docs/community-mesh-registry.md @@ -0,0 +1,67 @@ +# Community Mesh Registry + +The registry gives Meshtastic clients a reviewed, machine-readable list of +community radio configurations. Each community is one JSON file in +`data/communityMeshes/`, approved through the normal GitHub pull request review +process and published with this API. + +## Privacy model + +The API deliberately has no latitude/longitude lookup endpoint. Every client +downloads the same cacheable `GET /v1/community-meshes` response, uses each +record's GeoJSON bounding box as a fast first pass, and then performs the exact +point-in-polygon check on the device. A user's rough or precise location never +needs to leave the client. + +## Submit a community + +1. Copy `data/communityMeshes/_template.json` to a lowercase, hyphenated name + such as `data/communityMeshes/example-city.json`. +2. Give the record a stable, unique `id`, public name, description, and at least + one website or social link. +3. Draw the covered area as a GeoJSON `Polygon` or `MultiPolygon`. Coordinates + use `[longitude, latitude]`, and every polygon ring must end with the same + coordinate with which it starts. +4. Define one to three distinct `meshProfiles`. A profile is a separate radio + mesh, normally with its own primary channel and frequency settings. Put the + main choice first. Add secondary or tertiary profiles only when they exist. +5. Run `pnpm validate:community-meshes`, `pnpm test`, and `pnpm build`. +6. Open a pull request. A Meshtastic maintainer reviews the coverage, public + contact information, radio settings, and security implications before merge. + +## Radio and channel settings + +Use a standard modem preset whenever possible. For a custom modem, define all +required LoRa parameters in the schema, including bandwidth, spreading factor, +and coding rate. Radio fields can also describe frequency slot and offset, hop +limit, transmit power, MQTT behavior, and supported hardware-specific options. +An explicit frequency override is restricted to licensed profiles. + +Every unlicensed channel must have a valid Base64 PSK. A PSK in this public +repository is a shared channel key, not a secret and not an access-control +mechanism. Channel names may be no more than 12 UTF-8 bytes. The primary channel +is required; optional channels can be offered by clients as opt-in choices. + +For a licensed profile, include the `licensed` configuration and set every +channel's `pskBase64` to `null`. Clients are responsible for asking for the +operator's callsign and applying Meshtastic's licensed-mode setting before +switching to that profile. The registry does not store user callsigns. + +Profiles may set a minimum firmware version and maximum position or telemetry +intervals. The registry does not define position precision. + +## MQTT + +Omit `mqtt` when the community has no custom broker. When present, it can define +the public broker address or URL, port, TLS, username, password, root topic, +proxy-to-client behavior, JSON output, MQTT encryption, and map reporting +interval. Channel `uplinkEnabled` and `downlinkEnabled` flags control which +channels use it. + +All registry data is public. Never submit a private broker credential. Use a +dedicated, least-privilege account intended for public distribution, and rotate +it by updating the community record if necessary. + +The authoritative field definitions and limits are in +`schemas/community-mesh.schema.json`; the served copy is available at +`GET /v1/community-meshes/schema`. diff --git a/package.json b/package.json index 62531d9..2a7eff3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "scripts": { "dev": "tsx src/index.ts", "start": "prisma migrate deploy && node dist/index.js", - "build": "prisma generate && tsc" + "build": "prisma generate && tsc", + "test": "tsx --test tests/*.test.ts", + "validate:community-meshes": "tsx src/scripts/validateCommunityMeshes.ts" }, "dependencies": { "@buf/meshtastic_api.bufbuild_es": "1.7.2-20240110092216-3a147af14302.1", @@ -25,6 +27,7 @@ "@tinyhttp/favicon": "^3.0.0", "@tinyhttp/logger": "^2.0.0", "add": "^2.0.6", + "ajv": "^8.17.1", "mqtt": "^5.3.5", "octokit": "^3.1.2", "pnpm": "^8.15.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28bc99e..97ea186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: add: specifier: ^2.0.6 version: 2.0.6 + ajv: + specifier: ^8.17.1 + version: 8.20.0 mqtt: specifier: ^5.3.5 version: 5.3.5 @@ -642,6 +645,9 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -736,10 +742,16 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-unique-numbers@8.0.13: resolution: {integrity: sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==} engines: {node: '>=16.1.0'} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -779,6 +791,9 @@ packages: js-sdsl@4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -896,6 +911,10 @@ packages: reinterval@1.1.0: resolution: {integrity: sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1517,6 +1536,13 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + base64-js@1.5.1: {} before-after-hook@2.2.3: {} @@ -1607,11 +1633,15 @@ snapshots: events@3.3.0: {} + fast-deep-equal@3.1.3: {} + fast-unique-numbers@8.0.13: dependencies: '@babel/runtime': 7.23.9 tslib: 2.6.2 + fast-uri@3.1.3: {} + fsevents@2.3.3: optional: true @@ -1637,6 +1667,8 @@ snapshots: js-sdsl@4.3.0: {} + json-schema-traverse@1.0.0: {} + jsonwebtoken@9.0.2: dependencies: jws: 3.2.2 @@ -1789,6 +1821,8 @@ snapshots: reinterval@1.1.0: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rfdc@1.3.1: {} diff --git a/schemas/community-mesh.schema.json b/schemas/community-mesh.schema.json new file mode 100644 index 0000000..0fab628 --- /dev/null +++ b/schemas/community-mesh.schema.json @@ -0,0 +1,288 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.meshtastic.org/v1/community-meshes/schema", + "type": "object", + "required": [ + "schemaVersion", + "id", + "name", + "description", + "coverage", + "links", + "meshProfiles" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "coverage": { "$ref": "#/$defs/coverage" }, + "links": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/link" } + }, + "meshProfiles": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { "$ref": "#/$defs/profile" } + } + }, + "additionalProperties": false, + "$defs": { + "coverage": { + "oneOf": [ + { + "type": "object", + "required": ["type", "coordinates"], + "properties": { + "type": { "const": "Polygon" }, + "coordinates": { "$ref": "#/$defs/polygon" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "coordinates"], + "properties": { + "type": { "const": "MultiPolygon" }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/polygon" } + } + }, + "additionalProperties": false + } + ] + }, + "polygon": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { "type": "number", "minimum": -180, "maximum": 180 }, + { "type": "number", "minimum": -90, "maximum": 90 } + ], + "items": false + } + } + }, + "link": { + "type": "object", + "required": ["kind", "url"], + "properties": { + "kind": { + "enum": [ + "website", + "discord", + "matrix", + "telegram", + "facebook", + "instagram", + "github", + "other" + ] + }, + "url": { "type": "string", "pattern": "^https://" } + }, + "additionalProperties": false + }, + "profile": { + "type": "object", + "required": ["id", "name", "description", "radio", "primaryChannel"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "radio": { "$ref": "#/$defs/radio" }, + "primaryChannel": { "$ref": "#/$defs/channel" }, + "optionalChannels": { + "type": "array", + "maxItems": 7, + "items": { "$ref": "#/$defs/channel" } + }, + "mqtt": { "$ref": "#/$defs/mqtt" }, + "requirements": { "$ref": "#/$defs/requirements" }, + "licensed": { + "type": "object", + "required": ["required"], + "properties": { "required": { "const": true } }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "required": ["licensed"] }, + "then": { + "properties": { + "primaryChannel": { + "type": "object", + "properties": { "pskBase64": { "const": null } } + }, + "optionalChannels": { + "type": "array", + "items": { + "type": "object", + "properties": { "pskBase64": { "const": null } } + } + } + } + } + } + ] + }, + "radio": { + "type": "object", + "required": [ + "region", + "modem", + "frequencySlot", + "frequencyOffsetHz", + "maxHops", + "txPowerDbm", + "ignoreMqtt", + "sx126xRxBoostedGain" + ], + "properties": { + "region": { + "enum": [ + "US", + "EU_433", + "EU_868", + "CN", + "JP", + "ANZ", + "ANZ_433", + "KR", + "TW", + "RU", + "IN", + "NZ_865", + "TH", + "LORA_24", + "UA_433", + "UA_868", + "MY_433", + "MY_919", + "SG_923", + "KZ_433", + "KZ_863", + "BR_902", + "PH_433", + "PH_868", + "PH_915", + "NP_865" + ] + }, + "modem": { + "oneOf": [ + { "$ref": "#/$defs/presetModem" }, + { "$ref": "#/$defs/customModem" } + ] + }, + "frequencySlot": { "type": "integer", "minimum": 0 }, + "frequencyOffsetHz": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "overrideFrequencyMHz": { "type": "number", "exclusiveMinimum": 0 }, + "maxHops": { "type": "integer", "minimum": 1, "maximum": 7 }, + "txPowerDbm": { "type": "integer", "minimum": 0, "maximum": 30 }, + "ignoreMqtt": { "type": "boolean" }, + "sx126xRxBoostedGain": { "type": "boolean" } + }, + "additionalProperties": false + }, + "presetModem": { + "type": "object", + "required": ["kind", "preset"], + "properties": { + "kind": { "const": "preset" }, + "preset": { + "enum": [ + "SHORT_TURBO", + "SHORT_FAST", + "SHORT_SLOW", + "MEDIUM_FAST", + "MEDIUM_SLOW", + "LONG_FAST", + "LONG_MODERATE", + "LONG_SLOW", + "VERY_LONG_SLOW" + ] + } + }, + "additionalProperties": false + }, + "customModem": { + "type": "object", + "required": ["kind", "bandwidthKhz", "spreadFactor", "codingRate"], + "properties": { + "kind": { "const": "custom" }, + "bandwidthKhz": { + "enum": [31, 62, 125, 200, 250, 400, 500, 800, 1600] + }, + "spreadFactor": { "type": "integer", "minimum": 5, "maximum": 12 }, + "codingRate": { "type": "integer", "minimum": 5, "maximum": 8 } + }, + "additionalProperties": false + }, + "channel": { + "type": "object", + "required": ["name", "pskBase64", "uplinkEnabled", "downlinkEnabled"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 12 }, + "pskBase64": { "type": ["string", "null"] }, + "uplinkEnabled": { "type": "boolean" }, + "downlinkEnabled": { "type": "boolean" } + }, + "additionalProperties": false + }, + "mqtt": { + "type": "object", + "required": [ + "serverAddress", + "tlsEnabled", + "encryptionEnabled", + "jsonEnabled", + "proxyToClientEnabled", + "mapReportingEnabled" + ], + "properties": { + "serverAddress": { "type": "string", "minLength": 1 }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "tlsEnabled": { "type": "boolean" }, + "encryptionEnabled": { "type": "boolean" }, + "jsonEnabled": { "type": "boolean" }, + "rootTopic": { "type": "string" }, + "proxyToClientEnabled": { "type": "boolean" }, + "mapReportingEnabled": { "type": "boolean" }, + "mapReportingIntervalSeconds": { "type": "integer", "minimum": 3600 } + }, + "additionalProperties": false + }, + "requirements": { + "type": "object", + "properties": { + "minimumFirmware": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$" + }, + "positionIntervalMaxSeconds": { "type": "integer", "minimum": 1 }, + "telemetryIntervalMaxSeconds": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } +} diff --git a/src/index.ts b/src/index.ts index e486fc6..9ef4731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { favicon } from "@tinyhttp/favicon"; import { logger } from "@tinyhttp/logger"; import { RegisterMqttClient } from "./lib/index.js"; import { + CommunityMeshRoutes, DeviceLinksRoutes, EventFirmwareIconRoutes, EventFirmwareRoutes, @@ -41,6 +42,7 @@ app const whitelist = [ "http://localhost:3000", + "https://client.meshtastic.org", "https://meshtastic.org", "https://flash.meshtastic.org", "https://flasher.meshtastic.org", @@ -82,6 +84,7 @@ app * register Routes */ FirmwareRoutes(); +CommunityMeshRoutes(app); GithubRoutes(); ResourceRoutes(); DeviceLinksRoutes(); diff --git a/src/lib/communityMeshes.ts b/src/lib/communityMeshes.ts new file mode 100644 index 0000000..72c710d --- /dev/null +++ b/src/lib/communityMeshes.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv/dist/types/index.js"; + +const DATA_DIRECTORY = new URL("../../data/communityMeshes/", import.meta.url); +const SCHEMA_PATH = new URL( + "../../schemas/community-mesh.schema.json", + import.meta.url, +); + +export interface CommunityMesh { + id: string; + schemaVersion: 1; + [key: string]: unknown; +} + +export interface CommunityMeshesResponse { + apiVersion: "v1"; + schemaVersion: 1; + generatedAt: string; + sourceRevision: string; + communities: CommunityMesh[]; +} + +const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as AnySchema; +const validate = new Ajv2020({ allErrors: true }).compile(schema); + +const deepFreeze = (value: T): T => { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value as Record)) { + deepFreeze(nested); + } + } + return value; +}; + +const validPsk = (psk: unknown): boolean => { + if (psk === null) return true; + if (typeof psk !== "string") return false; + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + psk, + ) + ) { + return false; + } + return [0, 1, 16, 32].includes(Buffer.from(psk, "base64").length); +}; + +const validateSemantics = (record: CommunityMesh, filename: string): void => { + const coverage = record.coverage as { + type: "Polygon" | "MultiPolygon"; + coordinates: number[][][] | number[][][][]; + }; + const polygons = + coverage.type === "Polygon" + ? [coverage.coordinates as number[][][]] + : (coverage.coordinates as number[][][][]); + for (const polygon of polygons) { + for (const ring of polygon) { + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + throw new Error(`polygon ring is not closed in ${filename}`); + } + } + } + + const profileIds = new Set(); + const profiles = record.meshProfiles as Array>; + for (const profile of profiles) { + const id = profile.id as string; + if (profileIds.has(id)) + throw new Error(`duplicate profile id in ${filename}: ${id}`); + profileIds.add(id); + + const licensed = profile.licensed !== undefined; + const radio = profile.radio as Record; + if (radio.overrideFrequencyMHz !== undefined && !licensed) { + throw new Error( + `override frequency requires a licensed profile in ${filename}`, + ); + } + + const channels = [ + profile.primaryChannel, + ...((profile.optionalChannels as unknown[] | undefined) ?? []), + ] as Array>; + for (const channel of channels) { + if (Buffer.byteLength(channel.name as string, "utf8") > 12) { + throw new Error(`channel name exceeds 12 bytes in ${filename}`); + } + if (!validPsk(channel.pskBase64)) + throw new Error(`invalid PSK in ${filename}`); + if (licensed && channel.pskBase64 !== null) { + throw new Error( + `licensed profile contains an encrypted channel in ${filename}`, + ); + } + } + } +}; + +export const loadCommunityMeshes = ( + directory = DATA_DIRECTORY, +): CommunityMesh[] => { + const communities = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith(".json") && + !entry.name.startsWith("_"), + ) + .map((entry) => { + const record: unknown = JSON.parse( + readFileSync(new URL(entry.name, directory), "utf8"), + ); + if (!validate(record)) { + throw new Error( + `community mesh validation failed for ${entry.name}: ${JSON.stringify(validate.errors)}`, + ); + } + const community = record as CommunityMesh; + validateSemantics(community, entry.name); + return community; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + + for (let index = 1; index < communities.length; index += 1) { + if (communities[index - 1].id === communities[index].id) { + throw new Error(`duplicate community id: ${communities[index].id}`); + } + } + return deepFreeze(communities); +}; + +const communities = loadCommunityMeshes(); +const response: CommunityMeshesResponse = deepFreeze({ + apiVersion: "v1", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceRevision: process.env.VERCEL_GIT_COMMIT_SHA ?? "local", + communities, +}); + +export const registryJson = JSON.stringify(response, null, 2); + +export const registryEtag = `"${createHash("sha256") + .update(registryJson) + .digest("base64url")}"`; + +export const getCommunityMeshes = (): CommunityMeshesResponse => response; + +export const getCommunityMesh = (id: string): CommunityMesh | undefined => + communities.find((community) => community.id === id); + +export const getCommunityMeshSchema = (): unknown => schema; diff --git a/src/routes/communityMeshes.ts b/src/routes/communityMeshes.ts new file mode 100644 index 0000000..7d66cec --- /dev/null +++ b/src/routes/communityMeshes.ts @@ -0,0 +1,44 @@ +import type { App } from "@tinyhttp/app"; +import { + getCommunityMesh, + getCommunityMeshSchema, + registryEtag, + registryJson, +} from "../lib/communityMeshes.js"; + +const ifNoneMatchMatches = ( + header: string | undefined, + etag: string, +): boolean => { + if (!header) return false; + const validators = header.match(/(?:W\/)?"[^"]*"|\*/g) ?? []; + return validators.some( + (validator) => validator === "*" || validator.replace(/^W\//, "") === etag, + ); +}; + +export const CommunityMeshRoutes = (app: App): void => { + app.get("/v1/community-meshes", (req, res) => { + res.setHeader("Cache-Control", "public, max-age=3600"); + res.setHeader("ETag", registryEtag); + if (ifNoneMatchMatches(req.headers["if-none-match"], registryEtag)) { + return res.status(304).end(); + } + res.setHeader("Content-Type", "application/json; charset=utf-8"); + return res.send(registryJson); + }); + + app.get("/v1/community-meshes/schema", (_req, res) => { + res.setHeader("Cache-Control", "public, max-age=86400"); + return res.json(getCommunityMeshSchema()); + }); + + app.get("/v1/community-meshes/:id", (req, res) => { + const community = getCommunityMesh(req.params.id); + if (!community) { + return res.status(404).json({ error: "community_not_found" }); + } + res.setHeader("Cache-Control", "public, max-age=3600"); + return res.json(community); + }); +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 482c8a4..ca4b219 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,4 @@ +export { CommunityMeshRoutes } from "./communityMeshes.js"; export { DeviceLinksRoutes } from "./deviceLinks.js"; export { EventFirmwareRoutes } from "./eventFirmware.js"; export { EventFirmwareIconRoutes } from "./eventFirmwareIcon.js"; diff --git a/src/scripts/validateCommunityMeshes.ts b/src/scripts/validateCommunityMeshes.ts new file mode 100644 index 0000000..ad09011 --- /dev/null +++ b/src/scripts/validateCommunityMeshes.ts @@ -0,0 +1,4 @@ +import { getCommunityMeshes } from "../lib/communityMeshes.js"; + +const registry = getCommunityMeshes(); +console.log(`Validated ${registry.communities.length} community mesh records.`); diff --git a/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts new file mode 100644 index 0000000..c912333 --- /dev/null +++ b/tests/communityMeshes.routes.test.ts @@ -0,0 +1,83 @@ +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { App } from "@tinyhttp/app"; +import { CommunityMeshRoutes } from "../src/routes/communityMeshes.js"; +import { withServer } from "./helpers/http.js"; + +test("community registry returns a location-independent response", async () => { + const app = new App(); + CommunityMeshRoutes(app); + + await withServer(app, async (origin) => { + const response = await fetch(`${origin}/v1/community-meshes`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "public, max-age=3600"); + assert.deepEqual(Object.keys(await response.json()), [ + "apiVersion", + "schemaVersion", + "generatedAt", + "sourceRevision", + "communities", + ]); + }); +}); + +test("registry uses ETags for cache revalidation", async () => { + const app = new App(); + CommunityMeshRoutes(app); + + await withServer(app, async (origin) => { + const first = await fetch(`${origin}/v1/community-meshes`); + const etag = first.headers.get("etag"); + assert.ok(etag); + const body = await first.text(); + const expectedEtag = `"${createHash("sha256") + .update(body) + .digest("base64url")}"`; + assert.equal(etag, expectedEtag); + + const second = await fetch(`${origin}/v1/community-meshes`, { + headers: { "If-None-Match": etag }, + }); + assert.equal(second.status, 304); + assert.equal(second.headers.get("etag"), etag); + }); +}); + +test("registry accepts standard If-None-Match validator forms", async () => { + const app = new App(); + CommunityMeshRoutes(app); + + await withServer(app, async (origin) => { + const first = await fetch(`${origin}/v1/community-meshes`); + const etag = first.headers.get("etag"); + assert.ok(etag); + + const validators = [`W/${etag}`, `"unrelated", W/${etag}`, "*"]; + for (const validator of validators) { + const response = await fetch(`${origin}/v1/community-meshes`, { + headers: { "If-None-Match": validator }, + }); + assert.equal(response.status, 304, validator); + } + }); +}); + +test("serves the schema and returns 404 for an unknown community", async () => { + const app = new App(); + CommunityMeshRoutes(app); + + await withServer(app, async (origin) => { + const schema = await fetch(`${origin}/v1/community-meshes/schema`); + assert.equal(schema.status, 200); + assert.equal( + ((await schema.json()) as { $id: string }).$id, + "https://api.meshtastic.org/v1/community-meshes/schema", + ); + + const missing = await fetch(`${origin}/v1/community-meshes/not-found`); + assert.equal(missing.status, 404); + assert.deepEqual(await missing.json(), { error: "community_not_found" }); + }); +}); diff --git a/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts new file mode 100644 index 0000000..9701542 --- /dev/null +++ b/tests/communityMeshes.schema.test.ts @@ -0,0 +1,111 @@ +import { strict as assert } from "node:assert"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { loadCommunityMeshes } from "../src/lib/communityMeshes.js"; + +const fixture = (name: string): unknown => + JSON.parse( + readFileSync( + new URL(`./fixtures/communityMeshes/${name}`, import.meta.url), + "utf8", + ), + ); + +const schema = JSON.parse( + readFileSync( + new URL("../schemas/community-mesh.schema.json", import.meta.url), + "utf8", + ), +); +const validate = new Ajv2020({ allErrors: true }).compile(schema); + +test("the contributor template remains schema-valid", () => { + const template = JSON.parse( + readFileSync( + new URL("../data/communityMeshes/_template.json", import.meta.url), + "utf8", + ), + ); + assert.equal(validate(template), true, JSON.stringify(validate.errors)); +}); + +const withRecords = ( + records: unknown[], + callback: (directory: URL) => void, +): void => { + const directory = mkdtempSync(join(tmpdir(), "community-mesh-test-")); + try { + records.forEach((record, index) => { + writeFileSync(join(directory, `${index}.json`), JSON.stringify(record)); + }); + callback(pathToFileURL(`${directory}/`)); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}; + +test("accepts a public custom-modem profile with public MQTT", () => { + assert.equal(validate(fixture("valid-public-custom.json")), true); +}); + +test("loaded registry records are deeply immutable", () => { + withRecords([fixture("valid-public-custom.json")], (directory) => { + const communities = loadCommunityMeshes(directory); + const community = communities[0]; + const coverage = community.coverage as { coordinates: number[][][] }; + + assert.equal(Object.isFrozen(communities), true); + assert.equal(Object.isFrozen(community), true); + assert.equal(Object.isFrozen(coverage), true); + assert.equal(Object.isFrozen(coverage.coordinates), true); + assert.equal(Object.isFrozen(coverage.coordinates[0][0]), true); + }); +}); + +test("rejects a licensed profile with an encrypted channel", () => { + assert.equal(validate(fixture("invalid-licensed-psk.json")), false); +}); + +test("rejects a profile that defines both a preset and custom modem fields", () => { + assert.equal(validate(fixture("invalid-modem-union.json")), false); +}); + +test("rejects a schema-valid record with an invalid PSK encoding", () => { + assert.throws( + () => + loadCommunityMeshes( + new URL( + "./fixtures/communityMeshes/invalid-semantic/", + import.meta.url, + ), + ), + /invalid PSK/, + ); +}); + +test("rejects duplicate community ids", () => { + const record = fixture("valid-public-custom.json"); + withRecords([record, record], (directory) => { + assert.throws( + () => loadCommunityMeshes(directory), + /duplicate community id/, + ); + }); +}); + +test("rejects an open GeoJSON polygon ring", () => { + const record = structuredClone(fixture("valid-public-custom.json")) as { + coverage: { coordinates: number[][][] }; + }; + record.coverage.coordinates[0][3] = [2, 2]; + withRecords([record], (directory) => { + assert.throws( + () => loadCommunityMeshes(directory), + /polygon ring is not closed/, + ); + }); +}); diff --git a/tests/fixtures/communityMeshes/invalid-licensed-psk.json b/tests/fixtures/communityMeshes/invalid-licensed-psk.json new file mode 100644 index 0000000..ca44e86 --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-licensed-psk.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "id": "licensed-test", + "name": "Licensed Test", + "description": "Invalid encrypted licensed profile.", + "coverage": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] + }, + "links": [{ "kind": "website", "url": "https://example.org" }], + "meshProfiles": [ + { + "id": "licensed", + "name": "Licensed", + "description": "Licensed profile.", + "radio": { + "region": "US", + "modem": { "kind": "preset", "preset": "LONG_FAST" }, + "frequencySlot": 1, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Ham", + "pskBase64": "AQ==", + "uplinkEnabled": false, + "downlinkEnabled": false + }, + "licensed": { "required": true } + } + ] +} diff --git a/tests/fixtures/communityMeshes/invalid-modem-union.json b/tests/fixtures/communityMeshes/invalid-modem-union.json new file mode 100644 index 0000000..1f2f22a --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-modem-union.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "id": "bad-modem", + "name": "Bad Modem", + "description": "Invalid mixed modem profile.", + "coverage": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] + }, + "links": [{ "kind": "website", "url": "https://example.org" }], + "meshProfiles": [ + { + "id": "mixed", + "name": "Mixed", + "description": "Mixed modem profile.", + "radio": { + "region": "US", + "modem": { + "kind": "preset", + "preset": "LONG_FAST", + "bandwidthKhz": 250 + }, + "frequencySlot": 1, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Mixed", + "pskBase64": "AQ==", + "uplinkEnabled": false, + "downlinkEnabled": false + } + } + ] +} diff --git a/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json b/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json new file mode 100644 index 0000000..ca63467 --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "id": "bad-psk", + "name": "Bad PSK", + "description": "Schema-valid but invalid encoded PSK.", + "coverage": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] + }, + "links": [{ "kind": "website", "url": "https://example.org" }], + "meshProfiles": [ + { + "id": "public", + "name": "Public", + "description": "Invalid PSK profile.", + "radio": { + "region": "US", + "modem": { "kind": "preset", "preset": "LONG_FAST" }, + "frequencySlot": 1, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Bad", + "pskBase64": "not base64!", + "uplinkEnabled": false, + "downlinkEnabled": false + } + } + ] +} diff --git a/tests/fixtures/communityMeshes/valid-public-custom.json b/tests/fixtures/communityMeshes/valid-public-custom.json new file mode 100644 index 0000000..3d2fc38 --- /dev/null +++ b/tests/fixtures/communityMeshes/valid-public-custom.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "id": "test-community", + "name": "Test Community", + "description": "A test-only community record.", + "coverage": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] + }, + "links": [{ "kind": "website", "url": "https://example.org" }], + "meshProfiles": [ + { + "id": "custom", + "name": "Custom", + "description": "Custom radio test profile.", + "radio": { + "region": "US", + "modem": { + "kind": "custom", + "bandwidthKhz": 250, + "spreadFactor": 11, + "codingRate": 8 + }, + "frequencySlot": 12, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Test", + "pskBase64": "AQ==", + "uplinkEnabled": true, + "downlinkEnabled": true + }, + "mqtt": { + "serverAddress": "mqtt.example.org", + "username": "public", + "password": "public-password", + "tlsEnabled": true, + "encryptionEnabled": true, + "jsonEnabled": false, + "rootTopic": "msh/US/Test", + "proxyToClientEnabled": true, + "mapReportingEnabled": false, + "mapReportingIntervalSeconds": 3600 + }, + "requirements": { + "minimumFirmware": "2.7.0", + "positionIntervalMaxSeconds": 900, + "telemetryIntervalMaxSeconds": 1800 + } + } + ] +} diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts new file mode 100644 index 0000000..f4a1929 --- /dev/null +++ b/tests/helpers/http.ts @@ -0,0 +1,23 @@ +import type { AddressInfo } from "node:net"; +import type { App } from "@tinyhttp/app"; + +export const withServer = async ( + app: App, + callback: (origin: string) => Promise, +): Promise => { + let server: ReturnType | undefined; + await new Promise((resolve) => { + server = app.listen(0, resolve, "127.0.0.1"); + }); + if (!server) throw new Error("test server did not start"); + const startedServer = server; + const { port } = startedServer.address() as AddressInfo; + + try { + await callback(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => + startedServer.close((error) => (error ? reject(error) : resolve())), + ); + } +};