From 05c0e571d431d6c2e814040bcadcef6670f5a4c4 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 01/13] docs: specify community mesh registry --- ...26-07-13-community-mesh-registry-design.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md diff --git a/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md b/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md new file mode 100644 index 0000000..32e8cf0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md @@ -0,0 +1,275 @@ +# Community Mesh Registry Design + +## Goal + +Publish a reviewed, versioned registry of community Meshtastic meshes at +`api.meshtastic.org`. Client applications download the same public registry, +match an on-device location against community coverage locally, show distinct +mesh profiles, and apply one selected profile after the user reviews the +changes. + +The first deliverable is the registry API and GitHub-based approval workflow in +`meshtastic/api`. Apple, Android, Web, and CLI onboarding integrations are +follow-up projects that consume this stable contract. + +## Scope and ownership + +The registry is public configuration data, stored in the API repository. A +community administrator opens a pull request that changes one community JSON +file. Automated validation checks the submitted record. A Meshtastic +administrator reviews coverage, regulatory suitability, links, and any public +credentials, then merging the pull request approves and publishes the change. + +Version 1 does not add a database, an authenticated write API, a custom admin +portal, or a license-validation service. GitHub history is the approval audit +trail. A future portal may create the same pull request rather than becoming a +second source of truth. + +## Data source layout + +```text +data/communityMeshes/ + _template.json + .json +schemas/ + community-mesh.schema.json +src/lib/ + communityMeshes.ts +src/routes/ + communityMeshes.ts +docs/ + community-mesh-registry.md +.github/ + pull_request_template.md +``` + +Each file contains exactly one `CommunityMesh` record. The stable community ID +is lower-case kebab case and is also the filename. A profile ID is unique within +its community. Keeping records separate minimizes conflicts between unrelated +community submissions. + +## Community record + +```json +{ + "schemaVersion": 1, + "id": "portland-or", + "name": "Portland Mesh", + "description": "Community-operated Meshtastic coverage in Portland.", + "coverage": { + "type": "Polygon", + "coordinates": [[[-122.90, 45.40], [-122.40, 45.40], [-122.40, 45.70], [-122.90, 45.70], [-122.90, 45.40]]] + }, + "links": [ + { "kind": "website", "url": "https://example.org" }, + { "kind": "discord", "url": "https://discord.gg/example" } + ], + "meshProfiles": [] +} +``` + +`coverage` is a GeoJSON `Polygon` or `MultiPolygon` in WGS84 longitude, +latitude order. It represents an administrator's claimed service area, not a +radio-propagation guarantee. Links use a known kind (`website`, `discord`, +`matrix`, `telegram`, `facebook`, `instagram`, `github`, or `other`) and an +HTTPS URL. + +Every community defines one to three selectable `meshProfiles`. A profile is a +separate RF mesh, not a Meshtastic secondary channel. It has independent radio +configuration, a primary channel, optional extra channels, optional MQTT +configuration, and membership requirements. + +## Mesh profile + +```json +{ + "id": "portland-public", + "name": "Portland Public", + "description": "General-purpose community mesh.", + "radio": { + "region": "US", + "modem": { + "kind": "preset", + "preset": "MEDIUM_FAST" + }, + "frequencySlot": 12, + "frequencyOffsetHz": 0, + "maxHops": 3, + "txPowerDbm": 0, + "ignoreMqtt": false, + "sx126xRxBoostedGain": false + }, + "primaryChannel": { + "name": "Portland", + "pskBase64": "AQ==", + "uplinkEnabled": false, + "downlinkEnabled": false + }, + "optionalChannels": [], + "requirements": { + "minimumFirmware": "2.7.0", + "positionIntervalMaxSeconds": 900, + "telemetryIntervalMaxSeconds": 1800 + } +} +``` + +The radio modem is a discriminated union: + +```json +{ + "kind": "custom", + "bandwidthKhz": 250, + "spreadFactor": 11, + "codingRate": 8 +} +``` + +Preset mode specifies a Meshtastic modem preset. Custom mode must specify +bandwidth, spreading factor, and coding rate; it cannot also carry a preset. +`frequencySlot` is the configured LoRa channel number, where `0` retains +firmware's automatic primary-channel-name calculation. `frequencyOffsetHz`, +`maxHops`, `txPowerDbm`, `ignoreMqtt`, and `sx126xRxBoostedGain` map directly +to supported LoRa settings. + +The registry intentionally excludes device-local or unsafe LoRa controls: +transmit disablement, ignored-node lists, PA fan behavior, and duty-cycle +override. It also contains no position-precision setting. Requirements express +the maximum allowed position and telemetry intervals only when a community +needs to state them. + +An optional `overrideFrequencyMHz` is permitted only for a licensed profile. +This avoids distributing an out-of-band override as an ordinary public profile. + +## Channels and licensing + +`primaryChannel` is required. `optionalChannels` is an ordered list the client +offers to the user; it writes accepted channels consecutively after the primary +channel. Each channel has a name, a nullable `pskBase64`, and uplink/downlink +flags. A normal profile may publish a publicly joinable PSK because that is the +community's intended membership configuration. The value must decode to a +Meshtastic-supported PSK length. + +```json +{ + "licensed": { + "required": true + } +} +``` + +A licensed profile contains this object, requires every channel PSK to be +`null`, and may use `overrideFrequencyMHz`. Registry data never contains a +callsign. A client applying such a profile uses fixed product copy, requires +the user to enter a callsign and acknowledge eligibility, sets the callsign as +the device long name, enables `isLicensed`, and removes encryption from every +installed channel. The client does not claim to validate a radio license. + +## MQTT + +MQTT is absent by default. A community includes `mqtt` only when that mesh +requires custom MQTT configuration. It may specify all supported public +connection fields, including the broker address and intentionally public +username/password: + +```json +{ + "mqtt": { + "serverAddress": "mqtt.example.org", + "username": "community-user", + "password": "community-password", + "tlsEnabled": true, + "encryptionEnabled": true, + "jsonEnabled": false, + "rootTopic": "msh/US/Portland", + "proxyToClientEnabled": true, + "mapReportingEnabled": false, + "mapReportingIntervalSeconds": 3600 + } +} +``` + +All MQTT values in this public registry, including a password, are deliberately +public join configuration. Maintainers reject accidental secrets. The +per-channel `uplinkEnabled` and `downlinkEnabled` fields define whether the +profile permits gateway traffic; the MQTT module configuration alone does not +enable channel traffic. + +## API + +The route is versioned independently of existing API routes: + +| Endpoint | Response | +| --- | --- | +| `GET /v1/community-meshes` | The complete active registry, including coverage and public provisioning values. | +| `GET /v1/community-meshes/{id}` | One full community record for direct links or manual browsing. | +| `GET /v1/community-meshes/schema` | The current JSON Schema. | + +Every response includes `apiVersion`, `schemaVersion`, `generatedAt`, and the +source revision. Responses use `ETag`, compression, and public cache headers. +A missing ID returns `404`. The registry never accepts a latitude, longitude, +postal code, device identifier, or other user-specific lookup parameter. + +The complete registry is deliberately small enough to cache locally. Each +client makes the same periodic request regardless of its location, then uses a +bounding-box prefilter followed by an exact local point-in-polygon check against +the GeoJSON coverage. The location never leaves the device and is not persisted +for discovery. A user who declines location permission can pan a local map or +select a community manually. If registry growth makes the full download too +large, clients may retain the full index and offer opt-in country/region packs; +the default privacy-preserving request remains location-independent. + +## Client contract + +Client integrations refresh the common cached registry, find nearby profiles +locally, and show a configuration diff before writing any radio setting. They +must: + +1. Check minimum firmware, region/modem compatibility, and managed-mode status. +2. Let the user accept or decline every optional channel. +3. Prompt for a callsign before applying a licensed profile. +4. Save a local rollback snapshot. +5. Apply user, radio, primary/optional channels, interval requirements, and + optional MQTT configuration. +6. Reconnect after a radio reboot, read every setting back, and report a clear + failure if verification differs from the profile. +7. Restore the snapshot if the application transaction fails before completion. + +Client implementations are separate repository projects because their platform +transport, UI, and rollback facilities differ. They consume this API contract +without defining their own incompatible community data format. + +## Validation, test, and review policy + +The registry validator and tests must enforce: + +- JSON Schema validation for every source record and template. +- Valid Polygon and MultiPolygon rings, coordinate bounds, and non-empty + coverage. +- Unique community and profile IDs, one to three profiles per community, and + valid HTTPS links. +- Exactly one radio modem mode; supported custom modem ranges; valid region, + frequency slot, hops, and transmit-power values. +- Licensed profile encryption rules and the restriction on override frequency. +- Primary-channel name length, optional-channel ordering, valid Base64 PSKs, + and valid MQTT field combinations. +- Semantic minimum firmware versions and positive maximum-interval + requirements. +- On-device point-in-polygon lookup for ordinary polygons, holes, + multipolygons, and boundary cases, with bounding-box prefiltering. +- No location-bearing API route or request parameter. +- `404`, cache, schema, complete-registry, and full-record route behavior. + +The pull-request template requires the submitter to identify the community, +confirm authority to publish the coverage and credentials, describe radio and +licensed-operation legality, and state how maintainers can verify links. CI +runs registry validation, type checking, and formatting. A Meshtastic +administrator is the final approval authority. + +## Out of scope + +- A hosted submission form or self-service account system. +- Secret or per-user credential delivery. +- Radio-license verification. +- RF coverage measurement, propagation prediction, or uptime guarantees. +- Applying profiles directly from this API repository to a radio. From 99c48326c040f4a0f03e34beb80c0c8ca85cf8b1 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 02/13] docs: plan community mesh registry --- .../2026-07-13-community-mesh-registry.md | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-community-mesh-registry.md diff --git a/docs/superpowers/plans/2026-07-13-community-mesh-registry.md b/docs/superpowers/plans/2026-07-13-community-mesh-registry.md new file mode 100644 index 0000000..b6827bc --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-community-mesh-registry.md @@ -0,0 +1,508 @@ +# Community Mesh Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a reviewed, static community-mesh registry API to `meshtastic/api` that exposes public provisioning profiles without receiving any user location. + +**Architecture:** Community records live as one JSON file per mesh and are checked against a committed JSON Schema plus semantic rules at CI and process startup. A new route registers against a supplied TinyHTTP app, so focused Node tests can start only the registry routes; clients fetch one cacheable registry document and perform all GeoJSON location matching locally. + +**Tech Stack:** TypeScript (ESM), Node built-in test runner through `tsx`, TinyHTTP, Ajv JSON Schema validation, committed JSON data, Biome, pnpm 9. + +## Global Constraints + +- Define no location, postal-code, device-identifier, or other user-specific discovery query. +- `GET /v1/community-meshes` returns the complete active registry; no nearby-search endpoint exists. +- GeoJSON coverage is public Polygon or MultiPolygon data in longitude/latitude order. +- A community has 1-3 profiles; a profile is a separate radio configuration, not a secondary channel. +- A profile has either a named modem preset or custom bandwidth/spreading-factor/coding-rate, never both. +- Position precision is not represented anywhere in this schema or API. +- MQTT is omitted by default. When present, its URL, username, password, and all fields are intentionally public join configuration. +- A licensed profile requires all channel PSKs to be `null`; client applications prompt for a callsign and set Ham mode, but the API does not receive or validate callsigns. +- Preserve existing API routes and their behavior. + +--- + +### Task 1: Establish a focused Node test harness + +**Files:** +- Modify: `package.json` +- Create: `tests/helpers/http.ts` +- Create: `tests/communityMeshes.routes.test.ts` + +**Interfaces:** +- Consumes: `CommunityMeshRoutes(app: App): void` from Task 4. +- Produces: `withServer(app, callback): Promise` for HTTP route tests. + +- [ ] **Step 1: Add the test script and Ajv dependency** + +```json +{ + "scripts": { + "dev": "tsx src/index.ts", + "start": "prisma migrate deploy && node dist/index.js", + "build": "prisma generate && tsc", + "test": "tsx --test tests/*.test.ts", + "validate:community-meshes": "tsx src/scripts/validateCommunityMeshes.ts" + }, + "dependencies": { + "ajv": "^8.17.1" + } +} +``` + +- [ ] **Step 2: Add an HTTP server helper** + +```ts +import type { App } from "@tinyhttp/app"; +import type { AddressInfo } from "node:net"; + +export const withServer = async ( + app: App, + callback: (origin: string) => Promise, +): Promise => { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address() as AddressInfo; + + try { + await callback(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}; +``` + +- [ ] **Step 3: Write the first failing route test** + +```ts +import assert from "node:assert/strict"; +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", + ]); + }); +}); +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `pnpm test -- communityMeshes.routes.test.ts` + +Expected: FAIL because `src/routes/communityMeshes.ts` does not exist. + +- [ ] **Step 5: Install and lock the dependency** + +Run: `pnpm add ajv@^8.17.1` + +Expected: `package.json` and `pnpm-lock.yaml` include Ajv; no unrelated dependency versions change. + +- [ ] **Step 6: Commit the test harness** + +```bash +git add package.json pnpm-lock.yaml tests/helpers/http.ts tests/communityMeshes.routes.test.ts +git commit -m "test: add community registry harness" +``` + +### Task 2: Define committed registry data and the complete JSON Schema + +**Files:** +- Create: `schemas/community-mesh.schema.json` +- Create: `data/communityMeshes/_template.json` +- Create: `tests/communityMeshes.schema.test.ts` +- Create: `tests/fixtures/communityMeshes/valid-public-custom.json` +- Create: `tests/fixtures/communityMeshes/invalid-licensed-psk.json` +- Create: `tests/fixtures/communityMeshes/invalid-modem-union.json` + +**Interfaces:** +- Produces: `CommunityMesh` documents validated by `community-mesh.schema.json`. +- Consumed by: `loadCommunityMeshes()` in Task 3 and contributor documentation in Task 5. + +- [ ] **Step 1: Write failing schema tests** + +```ts +import Ajv2020 from "ajv/dist/2020.js"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +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("accepts a public custom-modem profile with public MQTT", () => { + assert.equal(validate(fixture("valid-public-custom.json")), 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); +}); +``` + +- [ ] **Step 2: Add the schema with these required definitions** + +```json +{ + "$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]+)*$" }, + "meshProfiles": { "type": "array", "minItems": 1, "maxItems": 3, "items": { "$ref": "#/$defs/profile" } } + }, + "$defs": { + "profile": { + "type": "object", + "required": ["id", "name", "description", "radio", "primaryChannel"], + "properties": { + "radio": { "$ref": "#/$defs/radio" }, + "primaryChannel": { "$ref": "#/$defs/channel" }, + "optionalChannels": { "type": "array", "items": { "$ref": "#/$defs/channel" } }, + "mqtt": { "$ref": "#/$defs/mqtt" }, + "licensed": { "type": "object", "required": ["required"], "properties": { "required": { "const": true } }, "additionalProperties": false } + }, + "additionalProperties": false + }, + "radio": { + "type": "object", + "required": ["region", "modem", "frequencySlot", "maxHops", "txPowerDbm", "ignoreMqtt", "sx126xRxBoostedGain"], + "properties": { "modem": { "oneOf": [{ "$ref": "#/$defs/presetModem" }, { "$ref": "#/$defs/customModem" }] } }, + "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": { "minimum": 5, "maximum": 12 }, "codingRate": { "minimum": 5, "maximum": 8 } }, "additionalProperties": false }, + "channel": { "type": "object", "required": ["name", "pskBase64", "uplinkEnabled", "downlinkEnabled"], "properties": { "name": { "type": "string", "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 } + } +} +``` + +Merge these exact properties and definitions into the same schema object above. +Do not add a position-precision property. + +```json +{ + "properties": { + "coverage": { + "oneOf": [ + { "type": "object", "required": ["type", "coordinates"], "properties": { "type": { "const": "Polygon" }, "coordinates": { "$ref": "#/$defs/polygonCoordinates" } }, "additionalProperties": false }, + { "type": "object", "required": ["type", "coordinates"], "properties": { "type": { "const": "MultiPolygon" }, "coordinates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/polygonCoordinates" } } }, "additionalProperties": false } + ] + }, + "links": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/link" } } + }, + "$defs": { + "position": { "type": "array", "minItems": 2, "maxItems": 2, "prefixItems": [{ "type": "number", "minimum": -180, "maximum": 180 }, { "type": "number", "minimum": -90, "maximum": 90 }], "items": false }, + "ring": { "type": "array", "minItems": 4, "items": { "$ref": "#/$defs/position" } }, + "polygonCoordinates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/ring" } }, + "link": { "type": "object", "required": ["kind", "url"], "properties": { "kind": { "enum": ["website", "discord", "matrix", "telegram", "facebook", "instagram", "github", "other"] }, "url": { "type": "string", "pattern": "^https://" } }, "additionalProperties": false } + } +} +``` + +The `radio.region` enum is exactly `UNSET`, `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`, and `NP_865`. `radio.frequencySlot` is an integer at least +zero; `frequencyOffsetHz` is an integer from zero through one million; +`maxHops` is an integer from one through seven; `txPowerDbm` is an integer from +zero through 30; `ignoreMqtt` and `sx126xRxBoostedGain` are booleans; and +`overrideFrequencyMHz` is an optional positive number. `requirements` permits +only `minimumFirmware` matching `^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$`, +`positionIntervalMaxSeconds` as a positive integer, and +`telemetryIntervalMaxSeconds` as a positive integer. Semantic validation, +not JSON Schema, enforces ring closure, profile IDs, channel byte length, PSK +decode length, and licensed-profile cross-field rules. + +- [ ] **Step 3: Add the contributor template** + +```json +{ + "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 }}] +} +``` + +- [ ] **Step 4: Run schema tests** + +Run: `pnpm test -- communityMeshes.schema.test.ts` + +Expected: PASS for valid fixture and FAIL assertions for each invalid fixture. + +- [ ] **Step 5: Commit schema and data template** + +```bash +git add schemas/community-mesh.schema.json data/communityMeshes/_template.json tests/communityMeshes.schema.test.ts tests/fixtures/communityMeshes +git commit -m "feat: define community mesh registry schema" +``` + +### Task 3: Load, validate, and expose typed registry data + +**Files:** +- Create: `src/lib/communityMeshes.ts` +- Create: `src/scripts/validateCommunityMeshes.ts` +- Create: `tests/communityMeshes.lib.test.ts` + +**Interfaces:** +- Produces: `CommunityMeshesResponse`, `getCommunityMeshes()`, `getCommunityMesh(id)`, `getCommunityMeshSchema()`, and `registryEtag`. +- Consumed by: `CommunityMeshRoutes()` in Task 4 and `validate:community-meshes` in CI. + +- [ ] **Step 1: Write failing library tests** + +```ts +test("skips the template and returns records ordered by id", () => { + assert.deepEqual(getCommunityMeshes().communities.map(({ id }) => id), ["alpha", "beta"]); +}); + +test("fails startup validation for duplicate profile ids and invalid Base64 PSKs", () => { + assert.throws(() => loadCommunityMeshes(fixtureDirectory("invalid")), /community mesh validation failed/); +}); +``` + +- [ ] **Step 2: Implement the loader and semantic checks** + +```ts +const DATA_DIRECTORY = new URL("../../data/communityMeshes/", import.meta.url); +const SCHEMA_PATH = new URL("../../schemas/community-mesh.schema.json", import.meta.url); + +export const loadCommunityMeshes = (directory = DATA_DIRECTORY): CommunityMeshesResponse => { + const communities = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.startsWith("_")) + .map((entry) => JSON.parse(readFileSync(new URL(entry.name, directory), "utf8")) as unknown) + .map(validateCommunityMesh) + .sort((left, right) => left.id.localeCompare(right.id)); + + validateUniqueIds(communities); + return Object.freeze({ apiVersion: "v1", schemaVersion: 1, generatedAt: new Date().toISOString(), sourceRevision: process.env.VERCEL_GIT_COMMIT_SHA ?? "local", communities }); +}; +``` + +Define `fixtureDirectory = (name: string) => new URL(`../../tests/fixtures/communityMeshes/${name}/`, import.meta.url);` beside the test imports. Implement `validateCommunityMesh()` with Ajv against the committed schema, then semantic checks for: geometry ring closure and coordinate bounds; profile IDs; optional channel count/order; byte-length channel names; Base64 PSK byte lengths of 0, 1, 16, or 32; licensed profiles with only `null` PSKs; and `overrideFrequencyMHz` only on licensed profiles. Throw one error containing the filename and every validation error. + +- [ ] **Step 3: Add a CLI validator** + +```ts +import { getCommunityMeshes } from "../lib/communityMeshes.js"; + +const registry = getCommunityMeshes(); +console.log(`Validated ${registry.communities.length} community mesh records.`); +``` + +- [ ] **Step 4: Run the focused tests and validator** + +Run: `pnpm test -- communityMeshes.lib.test.ts && pnpm validate:community-meshes` + +Expected: PASS; the command prints `Validated 0 community mesh records.` until a real community record is approved. + +- [ ] **Step 5: Commit the registry library** + +```bash +git add src/lib/communityMeshes.ts src/scripts/validateCommunityMeshes.ts tests/communityMeshes.lib.test.ts +git commit -m "feat: load validated community mesh records" +``` + +### Task 4: Serve cacheable, location-independent registry routes + +**Files:** +- Create: `src/routes/communityMeshes.ts` +- Modify: `src/routes/index.ts` +- Modify: `src/index.ts` +- Modify: `tests/communityMeshes.routes.test.ts` + +**Interfaces:** +- Consumes: `getCommunityMeshes()`, `getCommunityMesh(id)`, and `registryEtag` from Task 3. +- Produces: `CommunityMeshRoutes(app: App): void`. + +- [ ] **Step 1: Extend failing route tests** + +```ts +test("returns 304 when the registry ETag matches", async () => { + const first = await fetch(`${origin}/v1/community-meshes`); + const second = await fetch(`${origin}/v1/community-meshes`, { headers: { "If-None-Match": first.headers.get("etag") ?? "" } }); + assert.equal(second.status, 304); +}); + +test("serves the schema and returns 404 for an unknown community", async () => { + assert.equal((await fetch(`${origin}/v1/community-meshes/schema`)).status, 200); + assert.equal((await fetch(`${origin}/v1/community-meshes/not-found`)).status, 404); +}); +``` + +- [ ] **Step 2: Implement the injected route registrar** + +```ts +export const CommunityMeshRoutes = (app: App): void => { + app.get("/v1/community-meshes", (req, res) => { + const registry = getCommunityMeshes(); + if (req.headers["if-none-match"] === registryEtag) return res.status(304).end(); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.setHeader("ETag", registryEtag); + return res.json(registry); + }); + app.get("/v1/community-meshes/schema", (_req, res) => res.json(getCommunityMeshSchema())); + app.get("/v1/community-meshes/:id", (req, res) => { + const community = getCommunityMesh(req.params.id ?? ""); + return community ? res.json(community) : res.status(404).json({ error: "community_not_found" }); + }); +}; +``` + +- [ ] **Step 3: Register the route without changing legacy route imports** + +```ts +// src/routes/index.ts +export { CommunityMeshRoutes } from "./communityMeshes.js"; + +// src/index.ts +import { CommunityMeshRoutes, /* existing exports */ } from "./routes/index.js"; +CommunityMeshRoutes(app); +``` + +Add `https://client.meshtastic.org` to the existing CORS allowlist so the official web client can fetch the public registry. Do not broaden CORS for unrelated endpoints. + +- [ ] **Step 4: Run focused route tests** + +Run: `pnpm test -- communityMeshes.routes.test.ts` + +Expected: PASS for registry body, ETag 304, schema, and 404 behavior. + +- [ ] **Step 5: Commit routes** + +```bash +git add src/routes/communityMeshes.ts src/routes/index.ts src/index.ts tests/communityMeshes.routes.test.ts +git commit -m "feat: serve community mesh registry" +``` + +### Task 5: Document contribution and automate validation + +**Files:** +- Create: `docs/community-mesh-registry.md` +- Create: `.github/pull_request_template.md` +- Modify: `.github/workflows/ci.yml` +- Modify: `README.md` + +**Interfaces:** +- Consumes: schema, template, and `pnpm validate:community-meshes` from Tasks 2-3. +- Produces: a documented GitHub PR submission and Meshtastic-admin approval process. + +- [ ] **Step 1: Write the failing workflow expectation** + +Add this command locally before changing CI: + +Run: `pnpm validate:community-meshes` + +Expected before Task 3: FAIL because the command does not exist; after Task 3: PASS. + +- [ ] **Step 2: Add contributor instructions** + +```markdown +## Submit a community mesh + +1. Copy `data/communityMeshes/_template.json` to `data/communityMeshes/.json`. +2. Draw a closed GeoJSON Polygon or MultiPolygon in longitude/latitude order. +3. Define one to three distinct RF profiles. Use either a Meshtastic modem preset or all three custom modem values. +4. Omit `mqtt` unless the community needs custom MQTT. Every MQTT value, including `password`, is public when committed. +5. Use `licensed.required: true` only for a no-encryption profile. Clients will collect a callsign locally; never add it to this file. +6. Run `pnpm validate:community-meshes`, `pnpm test`, `pnpm build`, and `pnpm biome ci`. +7. Open a PR and complete the review checklist. +``` + +- [ ] **Step 3: Add reviewer checklist and CI command** + +```yaml +- name: Validate community mesh registry + run: pnpm validate:community-meshes + +- name: Test + run: pnpm test +``` + +The PR template must require confirmation that the submitter is authorized to publish coverage and all public MQTT credentials, that radio settings comply with the stated region/licensed operation, and that links are community-owned. + +- [ ] **Step 4: Run documentation checks** + +Run: `pnpm validate:community-meshes && pnpm test && pnpm build && pnpm biome ci` + +Expected: validator, tests, and build pass. Record pre-existing Biome warnings separately if they remain outside changed files; do not fix unrelated code. + +- [ ] **Step 5: Commit contributor workflow** + +```bash +git add docs/community-mesh-registry.md .github/pull_request_template.md .github/workflows/ci.yml README.md +git commit -m "docs: add community mesh submission workflow" +``` + +### Task 6: Verify the released contract end to end + +**Files:** +- Modify: `README.md` +- Test: `tests/communityMeshes.routes.test.ts` + +**Interfaces:** +- Consumes: every prior task. +- Produces: verified API examples for client teams. + +- [ ] **Step 1: Add a full-contract test fixture and expected response** + +```ts +const payload = await response.json() as { + apiVersion: string; + schemaVersion: number; + generatedAt: string; + sourceRevision: string; + communities: unknown[]; +}; +assert.equal(payload.apiVersion, "v1"); +assert.equal(payload.schemaVersion, 1); +assert.match(payload.generatedAt, /^\d{4}-\d{2}-\d{2}T/); +assert.match(payload.sourceRevision, /^(local|[0-9a-f]{7,64})$/); +assert.deepEqual(payload.communities, []); +``` + +- [ ] **Step 2: Run the full verification suite** + +Run: `pnpm validate:community-meshes && pnpm test && pnpm build && pnpm biome ci` + +Expected: all new validator, unit, and HTTP tests pass; TypeScript build passes; no new Biome diagnostics appear in changed files. + +- [ ] **Step 3: Manually verify no location leaves the client contract** + +Run: `rg -n "lat|lon|location|nearby" src/routes/communityMeshes.ts docs/community-mesh-registry.md README.md` + +Expected: `src/routes/communityMeshes.ts` contains no location-related route parameter or query handling. Documentation may only describe on-device matching. + +- [ ] **Step 4: Commit the final contract verification** + +```bash +git add README.md tests/communityMeshes.routes.test.ts +git commit -m "test: verify community mesh API contract" +``` From b08baf142c47e50ac6ba2753df0b969efb8989e0 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 03/13] test: add community registry harness --- package.json | 5 +++- pnpm-lock.yaml | 34 ++++++++++++++++++++++++++++ tests/communityMeshes.routes.test.ts | 23 +++++++++++++++++++ tests/helpers/http.ts | 19 ++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/communityMeshes.routes.test.ts create mode 100644 tests/helpers/http.ts 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/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts new file mode 100644 index 0000000..3e94489 --- /dev/null +++ b/tests/communityMeshes.routes.test.ts @@ -0,0 +1,23 @@ +import { App } from "@tinyhttp/app"; +import assert from "node:assert/strict"; +import test from "node:test"; +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", + ]); + }); +}); diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts new file mode 100644 index 0000000..57ffb4a --- /dev/null +++ b/tests/helpers/http.ts @@ -0,0 +1,19 @@ +import type { App } from "@tinyhttp/app"; +import type { AddressInfo } from "node:net"; + +export const withServer = async ( + app: App, + callback: (origin: string) => Promise, +): Promise => { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address() as AddressInfo; + + try { + await callback(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}; From 32631759a700008fadb3fce88df3479421e8f27f Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 04/13] feat: define community mesh registry schema --- data/communityMeshes/_template.json | 34 +++++++++ schemas/community-mesh.schema.json | 74 +++++++++++++++++++ tests/communityMeshes.schema.test.ts | 32 ++++++++ .../communityMeshes/invalid-licensed-psk.json | 16 ++++ .../communityMeshes/invalid-modem-union.json | 15 ++++ .../communityMeshes/valid-public-custom.json | 37 ++++++++++ 6 files changed, 208 insertions(+) create mode 100644 data/communityMeshes/_template.json create mode 100644 schemas/community-mesh.schema.json create mode 100644 tests/communityMeshes.schema.test.ts create mode 100644 tests/fixtures/communityMeshes/invalid-licensed-psk.json create mode 100644 tests/fixtures/communityMeshes/invalid-modem-union.json create mode 100644 tests/fixtures/communityMeshes/valid-public-custom.json 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/schemas/community-mesh.schema.json b/schemas/community-mesh.schema.json new file mode 100644 index 0000000..8871b04 --- /dev/null +++ b/schemas/community-mesh.schema.json @@ -0,0 +1,74 @@ +{ + "$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/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts new file mode 100644 index 0000000..59483be --- /dev/null +++ b/tests/communityMeshes.schema.test.ts @@ -0,0 +1,32 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +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("accepts a public custom-modem profile with public MQTT", () => { + assert.equal(validate(fixture("valid-public-custom.json")), 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); +}); diff --git a/tests/fixtures/communityMeshes/invalid-licensed-psk.json b/tests/fixtures/communityMeshes/invalid-licensed-psk.json new file mode 100644 index 0000000..2f6b24b --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-licensed-psk.json @@ -0,0 +1,16 @@ +{ + "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..4018084 --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-modem-union.json @@ -0,0 +1,15 @@ +{ + "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/valid-public-custom.json b/tests/fixtures/communityMeshes/valid-public-custom.json new file mode 100644 index 0000000..1189f20 --- /dev/null +++ b/tests/fixtures/communityMeshes/valid-public-custom.json @@ -0,0 +1,37 @@ +{ + "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 } + }] +} From bb48e34844c325bcbc65b880bc53e5013179be8e Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 05/13] feat: serve validated community mesh registry --- src/index.ts | 2 + src/lib/communityMeshes.ts | 103 ++++++++++++++++++ src/routes/communityMeshes.ts | 27 +++++ src/routes/index.ts | 1 + tests/communityMeshes.routes.test.ts | 2 +- tests/communityMeshes.schema.test.ts | 15 ++- .../invalid-semantic/invalid-psk.json | 15 +++ tests/helpers/http.ts | 12 +- 8 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 src/lib/communityMeshes.ts create mode 100644 src/routes/communityMeshes.ts create mode 100644 tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json diff --git a/src/index.ts b/src/index.ts index e486fc6..68a761c 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, @@ -82,6 +83,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..8a91b8b --- /dev/null +++ b/src/lib/communityMeshes.ts @@ -0,0 +1,103 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv/dist/types/index.js"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; + +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 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 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[] => + 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)); + +const communities = loadCommunityMeshes(); +const response: CommunityMeshesResponse = Object.freeze({ + apiVersion: "v1", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceRevision: process.env.VERCEL_GIT_COMMIT_SHA ?? "local", + communities, +}); + +export const registryEtag = `"${createHash("sha256") + .update(JSON.stringify(communities)) + .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..f9e8324 --- /dev/null +++ b/src/routes/communityMeshes.ts @@ -0,0 +1,27 @@ +import type { App } from "@tinyhttp/app"; +import { + getCommunityMesh, + getCommunityMeshes, + getCommunityMeshSchema, + registryEtag, +} from "../lib/communityMeshes.js"; + +export const CommunityMeshRoutes = (app: App): void => { + app.get("/v1/community-meshes", (req, res) => { + if (req.headers["if-none-match"] === registryEtag) { + return res.status(304).end(); + } + res.setHeader("Cache-Control", "public, max-age=3600"); + res.setHeader("ETag", registryEtag); + return res.json(getCommunityMeshes()); + }); + app.get("/v1/community-meshes/schema", (_req, res) => + res.json(getCommunityMeshSchema()), + ); + app.get("/v1/community-meshes/:id", (req, res) => { + const community = getCommunityMesh(req.params.id ?? ""); + return community + ? res.json(community) + : res.status(404).json({ error: "community_not_found" }); + }); +}; 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/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts index 3e94489..80d68c6 100644 --- a/tests/communityMeshes.routes.test.ts +++ b/tests/communityMeshes.routes.test.ts @@ -1,5 +1,5 @@ import { App } from "@tinyhttp/app"; -import assert from "node:assert/strict"; +import { strict as assert } from "node:assert"; import test from "node:test"; import { CommunityMeshRoutes } from "../src/routes/communityMeshes.js"; import { withServer } from "./helpers/http.js"; diff --git a/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts index 59483be..bc6039f 100644 --- a/tests/communityMeshes.schema.test.ts +++ b/tests/communityMeshes.schema.test.ts @@ -1,7 +1,8 @@ -import Ajv2020 from "ajv/dist/2020.js"; -import assert from "node:assert/strict"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { loadCommunityMeshes } from "../src/lib/communityMeshes.js"; const fixture = (name: string): unknown => JSON.parse( @@ -30,3 +31,13 @@ test("rejects a licensed profile with an encrypted channel", () => { 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/, + ); +}); 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..219046b --- /dev/null +++ b/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json @@ -0,0 +1,15 @@ +{ + "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/helpers/http.ts b/tests/helpers/http.ts index 57ffb4a..557b13c 100644 --- a/tests/helpers/http.ts +++ b/tests/helpers/http.ts @@ -5,15 +5,19 @@ export const withServer = async ( app: App, callback: (origin: string) => Promise, ): Promise => { - const server = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => server.once("listening", resolve)); - const { port } = server.address() as AddressInfo; + 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) => - server.close((error) => (error ? reject(error) : resolve())), + startedServer.close((error) => (error ? reject(error) : resolve())), ); } }; From 7c3df41ee5090b6c9a2bb965b2a60bb799eb0395 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 06/13] feat: validate community mesh registry data --- schemas/community-mesh.schema.json | 254 +++++++++++++++++++++++-- src/lib/communityMeshes.ts | 62 +++++- src/scripts/validateCommunityMeshes.ts | 4 + tests/communityMeshes.schema.test.ts | 58 +++++- 4 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 src/scripts/validateCommunityMeshes.ts diff --git a/schemas/community-mesh.schema.json b/schemas/community-mesh.schema.json index 8871b04..0fab628 100644 --- a/schemas/community-mesh.schema.json +++ b/schemas/community-mesh.schema.json @@ -2,26 +2,99 @@ "$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"], + "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" } } + "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 } + { + "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 }, + "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"], @@ -31,10 +104,19 @@ "description": { "type": "string", "minLength": 1 }, "radio": { "$ref": "#/$defs/radio" }, "primaryChannel": { "$ref": "#/$defs/channel" }, - "optionalChannels": { "type": "array", "maxItems": 7, "items": { "$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 } + "licensed": { + "type": "object", + "required": ["required"], + "properties": { "required": { "const": true } }, + "additionalProperties": false + } }, "additionalProperties": false, "allOf": [ @@ -42,8 +124,17 @@ "if": { "required": ["licensed"] }, "then": { "properties": { - "primaryChannel": { "type": "object", "properties": { "pskBase64": { "const": null } } }, - "optionalChannels": { "type": "array", "items": { "type": "object", "properties": { "pskBase64": { "const": null } } } } + "primaryChannel": { + "type": "object", + "properties": { "pskBase64": { "const": null } } + }, + "optionalChannels": { + "type": "array", + "items": { + "type": "object", + "properties": { "pskBase64": { "const": null } } + } + } } } } @@ -51,12 +142,59 @@ }, "radio": { "type": "object", - "required": ["region", "modem", "frequencySlot", "frequencyOffsetHz", "maxHops", "txPowerDbm", "ignoreMqtt", "sx126xRxBoostedGain"], + "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" }] }, + "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 }, + "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 }, @@ -65,10 +203,86 @@ }, "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 } + "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/lib/communityMeshes.ts b/src/lib/communityMeshes.ts index 8a91b8b..de990f4 100644 --- a/src/lib/communityMeshes.ts +++ b/src/lib/communityMeshes.ts @@ -4,7 +4,10 @@ import { createHash } from "node:crypto"; import { readdirSync, readFileSync } from "node:fs"; const DATA_DIRECTORY = new URL("../../data/communityMeshes/", import.meta.url); -const SCHEMA_PATH = new URL("../../schemas/community-mesh.schema.json", import.meta.url); +const SCHEMA_PATH = new URL( + "../../schemas/community-mesh.schema.json", + import.meta.url, +); export interface CommunityMesh { id: string; @@ -26,41 +29,74 @@ const validate = new Ajv2020({ allErrors: true }).compile(schema); 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)) { + 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}`); + 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}`); + throw new Error( + `override frequency requires a licensed profile in ${filename}`, + ); } - const channels = [profile.primaryChannel, ...((profile.optionalChannels as unknown[] | undefined) ?? [])] as Array>; + 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 (!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}`); + throw new Error( + `licensed profile contains an encrypted channel in ${filename}`, + ); } } } }; -export const loadCommunityMeshes = (directory = DATA_DIRECTORY): CommunityMesh[] => - readdirSync(directory, { withFileTypes: true }) +export const loadCommunityMeshes = ( + directory = DATA_DIRECTORY, +): CommunityMesh[] => { + const communities = readdirSync(directory, { withFileTypes: true }) .filter( (entry) => entry.isFile() && @@ -82,6 +118,14 @@ export const loadCommunityMeshes = (directory = DATA_DIRECTORY): CommunityMesh[] }) .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 communities; +}; + const communities = loadCommunityMeshes(); const response: CommunityMeshesResponse = Object.freeze({ apiVersion: "v1", 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.schema.test.ts b/tests/communityMeshes.schema.test.ts index bc6039f..b5cee64 100644 --- a/tests/communityMeshes.schema.test.ts +++ b/tests/communityMeshes.schema.test.ts @@ -1,6 +1,9 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { strict as assert } from "node:assert"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import test from "node:test"; import { loadCommunityMeshes } from "../src/lib/communityMeshes.js"; @@ -20,6 +23,31 @@ const schema = JSON.parse( ); 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); }); @@ -36,8 +64,34 @@ test("rejects a schema-valid record with an invalid PSK encoding", () => { assert.throws( () => loadCommunityMeshes( - new URL("./fixtures/communityMeshes/invalid-semantic/", import.meta.url), + 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/, + ); + }); +}); From d87baa56aad3853ea127e04125263f5bb3892f2e Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 07/13] feat: complete community registry HTTP contract --- src/index.ts | 1 + src/routes/communityMeshes.ts | 23 +++++++++++------- tests/communityMeshes.routes.test.ts | 35 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 68a761c..9ef4731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ app const whitelist = [ "http://localhost:3000", + "https://client.meshtastic.org", "https://meshtastic.org", "https://flash.meshtastic.org", "https://flasher.meshtastic.org", diff --git a/src/routes/communityMeshes.ts b/src/routes/communityMeshes.ts index f9e8324..5baeeb3 100644 --- a/src/routes/communityMeshes.ts +++ b/src/routes/communityMeshes.ts @@ -8,20 +8,25 @@ import { 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 (req.headers["if-none-match"] === registryEtag) { return res.status(304).end(); } - res.setHeader("Cache-Control", "public, max-age=3600"); - res.setHeader("ETag", registryEtag); return res.json(getCommunityMeshes()); }); - app.get("/v1/community-meshes/schema", (_req, res) => - res.json(getCommunityMeshSchema()), - ); + + 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 ?? ""); - return community - ? res.json(community) - : res.status(404).json({ error: "community_not_found" }); + 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/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts index 80d68c6..e8c7ac9 100644 --- a/tests/communityMeshes.routes.test.ts +++ b/tests/communityMeshes.routes.test.ts @@ -21,3 +21,38 @@ test("community registry returns a location-independent response", async () => { ]); }); }); + +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 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("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" }); + }); +}); From f6bb7b67cb6b5172b77771d5ff006872356121cb Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 08/13] docs: add community mesh submission workflow --- .github/pull_request_template.md | 14 +++++++ .github/workflows/ci.yml | 6 +++ README.md | 11 ++++++ docs/community-mesh-registry.md | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 docs/community-mesh-registry.md 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/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`. From 42ce36b1884744c88ad349f82f22167cc490454b Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 09/13] style: organize community registry imports --- src/lib/communityMeshes.ts | 4 ++-- tests/communityMeshes.routes.test.ts | 2 +- tests/communityMeshes.schema.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/communityMeshes.ts b/src/lib/communityMeshes.ts index de990f4..a9a894c 100644 --- a/src/lib/communityMeshes.ts +++ b/src/lib/communityMeshes.ts @@ -1,7 +1,7 @@ -import { Ajv2020 } from "ajv/dist/2020.js"; -import type { AnySchema } from "ajv/dist/types/index.js"; 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( diff --git a/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts index e8c7ac9..8bd9827 100644 --- a/tests/communityMeshes.routes.test.ts +++ b/tests/communityMeshes.routes.test.ts @@ -1,6 +1,6 @@ -import { App } from "@tinyhttp/app"; import { strict as assert } from "node:assert"; import test from "node:test"; +import { App } from "@tinyhttp/app"; import { CommunityMeshRoutes } from "../src/routes/communityMeshes.js"; import { withServer } from "./helpers/http.js"; diff --git a/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts index b5cee64..ac473ca 100644 --- a/tests/communityMeshes.schema.test.ts +++ b/tests/communityMeshes.schema.test.ts @@ -1,10 +1,10 @@ -import { Ajv2020 } from "ajv/dist/2020.js"; 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 { pathToFileURL } from "node:url"; 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 => From cb3313588cbc550fa2b04fb900853cc869a52b94 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 10/13] style: format community registry test fixtures --- .../communityMeshes/invalid-licensed-psk.json | 37 ++++++--- .../communityMeshes/invalid-modem-union.json | 39 +++++++-- .../invalid-semantic/invalid-psk.json | 35 ++++++-- .../communityMeshes/valid-public-custom.json | 79 ++++++++++++------- tests/helpers/http.ts | 2 +- 5 files changed, 136 insertions(+), 56 deletions(-) diff --git a/tests/fixtures/communityMeshes/invalid-licensed-psk.json b/tests/fixtures/communityMeshes/invalid-licensed-psk.json index 2f6b24b..ca44e86 100644 --- a/tests/fixtures/communityMeshes/invalid-licensed-psk.json +++ b/tests/fixtures/communityMeshes/invalid-licensed-psk.json @@ -3,14 +3,33 @@ "id": "licensed-test", "name": "Licensed Test", "description": "Invalid encrypted licensed profile.", - "coverage": { "type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] }, + "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 } - }] + "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 index 4018084..1f2f22a 100644 --- a/tests/fixtures/communityMeshes/invalid-modem-union.json +++ b/tests/fixtures/communityMeshes/invalid-modem-union.json @@ -3,13 +3,36 @@ "id": "bad-modem", "name": "Bad Modem", "description": "Invalid mixed modem profile.", - "coverage": { "type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] }, + "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 } - }] + "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 index 219046b..ca63467 100644 --- a/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json +++ b/tests/fixtures/communityMeshes/invalid-semantic/invalid-psk.json @@ -3,13 +3,32 @@ "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]]] }, + "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 } - }] + "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 index 1189f20..3d2fc38 100644 --- a/tests/fixtures/communityMeshes/valid-public-custom.json +++ b/tests/fixtures/communityMeshes/valid-public-custom.json @@ -3,35 +3,54 @@ "id": "test-community", "name": "Test Community", "description": "A test-only community record.", - "coverage": { "type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]] }, + "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 } - }] + "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 index 557b13c..f4a1929 100644 --- a/tests/helpers/http.ts +++ b/tests/helpers/http.ts @@ -1,5 +1,5 @@ -import type { App } from "@tinyhttp/app"; import type { AddressInfo } from "node:net"; +import type { App } from "@tinyhttp/app"; export const withServer = async ( app: App, From ee6416d1082f3e2b0799e0ef7424948a304da018 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 11/13] chore: remove internal planning documents --- .../2026-07-13-community-mesh-registry.md | 508 ------------------ ...26-07-13-community-mesh-registry-design.md | 275 ---------- 2 files changed, 783 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-13-community-mesh-registry.md delete mode 100644 docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md diff --git a/docs/superpowers/plans/2026-07-13-community-mesh-registry.md b/docs/superpowers/plans/2026-07-13-community-mesh-registry.md deleted file mode 100644 index b6827bc..0000000 --- a/docs/superpowers/plans/2026-07-13-community-mesh-registry.md +++ /dev/null @@ -1,508 +0,0 @@ -# Community Mesh Registry Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a reviewed, static community-mesh registry API to `meshtastic/api` that exposes public provisioning profiles without receiving any user location. - -**Architecture:** Community records live as one JSON file per mesh and are checked against a committed JSON Schema plus semantic rules at CI and process startup. A new route registers against a supplied TinyHTTP app, so focused Node tests can start only the registry routes; clients fetch one cacheable registry document and perform all GeoJSON location matching locally. - -**Tech Stack:** TypeScript (ESM), Node built-in test runner through `tsx`, TinyHTTP, Ajv JSON Schema validation, committed JSON data, Biome, pnpm 9. - -## Global Constraints - -- Define no location, postal-code, device-identifier, or other user-specific discovery query. -- `GET /v1/community-meshes` returns the complete active registry; no nearby-search endpoint exists. -- GeoJSON coverage is public Polygon or MultiPolygon data in longitude/latitude order. -- A community has 1-3 profiles; a profile is a separate radio configuration, not a secondary channel. -- A profile has either a named modem preset or custom bandwidth/spreading-factor/coding-rate, never both. -- Position precision is not represented anywhere in this schema or API. -- MQTT is omitted by default. When present, its URL, username, password, and all fields are intentionally public join configuration. -- A licensed profile requires all channel PSKs to be `null`; client applications prompt for a callsign and set Ham mode, but the API does not receive or validate callsigns. -- Preserve existing API routes and their behavior. - ---- - -### Task 1: Establish a focused Node test harness - -**Files:** -- Modify: `package.json` -- Create: `tests/helpers/http.ts` -- Create: `tests/communityMeshes.routes.test.ts` - -**Interfaces:** -- Consumes: `CommunityMeshRoutes(app: App): void` from Task 4. -- Produces: `withServer(app, callback): Promise` for HTTP route tests. - -- [ ] **Step 1: Add the test script and Ajv dependency** - -```json -{ - "scripts": { - "dev": "tsx src/index.ts", - "start": "prisma migrate deploy && node dist/index.js", - "build": "prisma generate && tsc", - "test": "tsx --test tests/*.test.ts", - "validate:community-meshes": "tsx src/scripts/validateCommunityMeshes.ts" - }, - "dependencies": { - "ajv": "^8.17.1" - } -} -``` - -- [ ] **Step 2: Add an HTTP server helper** - -```ts -import type { App } from "@tinyhttp/app"; -import type { AddressInfo } from "node:net"; - -export const withServer = async ( - app: App, - callback: (origin: string) => Promise, -): Promise => { - const server = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => server.once("listening", resolve)); - const { port } = server.address() as AddressInfo; - - try { - await callback(`http://127.0.0.1:${port}`); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -}; -``` - -- [ ] **Step 3: Write the first failing route test** - -```ts -import assert from "node:assert/strict"; -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", - ]); - }); -}); -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `pnpm test -- communityMeshes.routes.test.ts` - -Expected: FAIL because `src/routes/communityMeshes.ts` does not exist. - -- [ ] **Step 5: Install and lock the dependency** - -Run: `pnpm add ajv@^8.17.1` - -Expected: `package.json` and `pnpm-lock.yaml` include Ajv; no unrelated dependency versions change. - -- [ ] **Step 6: Commit the test harness** - -```bash -git add package.json pnpm-lock.yaml tests/helpers/http.ts tests/communityMeshes.routes.test.ts -git commit -m "test: add community registry harness" -``` - -### Task 2: Define committed registry data and the complete JSON Schema - -**Files:** -- Create: `schemas/community-mesh.schema.json` -- Create: `data/communityMeshes/_template.json` -- Create: `tests/communityMeshes.schema.test.ts` -- Create: `tests/fixtures/communityMeshes/valid-public-custom.json` -- Create: `tests/fixtures/communityMeshes/invalid-licensed-psk.json` -- Create: `tests/fixtures/communityMeshes/invalid-modem-union.json` - -**Interfaces:** -- Produces: `CommunityMesh` documents validated by `community-mesh.schema.json`. -- Consumed by: `loadCommunityMeshes()` in Task 3 and contributor documentation in Task 5. - -- [ ] **Step 1: Write failing schema tests** - -```ts -import Ajv2020 from "ajv/dist/2020.js"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; - -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("accepts a public custom-modem profile with public MQTT", () => { - assert.equal(validate(fixture("valid-public-custom.json")), 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); -}); -``` - -- [ ] **Step 2: Add the schema with these required definitions** - -```json -{ - "$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]+)*$" }, - "meshProfiles": { "type": "array", "minItems": 1, "maxItems": 3, "items": { "$ref": "#/$defs/profile" } } - }, - "$defs": { - "profile": { - "type": "object", - "required": ["id", "name", "description", "radio", "primaryChannel"], - "properties": { - "radio": { "$ref": "#/$defs/radio" }, - "primaryChannel": { "$ref": "#/$defs/channel" }, - "optionalChannels": { "type": "array", "items": { "$ref": "#/$defs/channel" } }, - "mqtt": { "$ref": "#/$defs/mqtt" }, - "licensed": { "type": "object", "required": ["required"], "properties": { "required": { "const": true } }, "additionalProperties": false } - }, - "additionalProperties": false - }, - "radio": { - "type": "object", - "required": ["region", "modem", "frequencySlot", "maxHops", "txPowerDbm", "ignoreMqtt", "sx126xRxBoostedGain"], - "properties": { "modem": { "oneOf": [{ "$ref": "#/$defs/presetModem" }, { "$ref": "#/$defs/customModem" }] } }, - "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": { "minimum": 5, "maximum": 12 }, "codingRate": { "minimum": 5, "maximum": 8 } }, "additionalProperties": false }, - "channel": { "type": "object", "required": ["name", "pskBase64", "uplinkEnabled", "downlinkEnabled"], "properties": { "name": { "type": "string", "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 } - } -} -``` - -Merge these exact properties and definitions into the same schema object above. -Do not add a position-precision property. - -```json -{ - "properties": { - "coverage": { - "oneOf": [ - { "type": "object", "required": ["type", "coordinates"], "properties": { "type": { "const": "Polygon" }, "coordinates": { "$ref": "#/$defs/polygonCoordinates" } }, "additionalProperties": false }, - { "type": "object", "required": ["type", "coordinates"], "properties": { "type": { "const": "MultiPolygon" }, "coordinates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/polygonCoordinates" } } }, "additionalProperties": false } - ] - }, - "links": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/link" } } - }, - "$defs": { - "position": { "type": "array", "minItems": 2, "maxItems": 2, "prefixItems": [{ "type": "number", "minimum": -180, "maximum": 180 }, { "type": "number", "minimum": -90, "maximum": 90 }], "items": false }, - "ring": { "type": "array", "minItems": 4, "items": { "$ref": "#/$defs/position" } }, - "polygonCoordinates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/ring" } }, - "link": { "type": "object", "required": ["kind", "url"], "properties": { "kind": { "enum": ["website", "discord", "matrix", "telegram", "facebook", "instagram", "github", "other"] }, "url": { "type": "string", "pattern": "^https://" } }, "additionalProperties": false } - } -} -``` - -The `radio.region` enum is exactly `UNSET`, `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`, and `NP_865`. `radio.frequencySlot` is an integer at least -zero; `frequencyOffsetHz` is an integer from zero through one million; -`maxHops` is an integer from one through seven; `txPowerDbm` is an integer from -zero through 30; `ignoreMqtt` and `sx126xRxBoostedGain` are booleans; and -`overrideFrequencyMHz` is an optional positive number. `requirements` permits -only `minimumFirmware` matching `^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$`, -`positionIntervalMaxSeconds` as a positive integer, and -`telemetryIntervalMaxSeconds` as a positive integer. Semantic validation, -not JSON Schema, enforces ring closure, profile IDs, channel byte length, PSK -decode length, and licensed-profile cross-field rules. - -- [ ] **Step 3: Add the contributor template** - -```json -{ - "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 }}] -} -``` - -- [ ] **Step 4: Run schema tests** - -Run: `pnpm test -- communityMeshes.schema.test.ts` - -Expected: PASS for valid fixture and FAIL assertions for each invalid fixture. - -- [ ] **Step 5: Commit schema and data template** - -```bash -git add schemas/community-mesh.schema.json data/communityMeshes/_template.json tests/communityMeshes.schema.test.ts tests/fixtures/communityMeshes -git commit -m "feat: define community mesh registry schema" -``` - -### Task 3: Load, validate, and expose typed registry data - -**Files:** -- Create: `src/lib/communityMeshes.ts` -- Create: `src/scripts/validateCommunityMeshes.ts` -- Create: `tests/communityMeshes.lib.test.ts` - -**Interfaces:** -- Produces: `CommunityMeshesResponse`, `getCommunityMeshes()`, `getCommunityMesh(id)`, `getCommunityMeshSchema()`, and `registryEtag`. -- Consumed by: `CommunityMeshRoutes()` in Task 4 and `validate:community-meshes` in CI. - -- [ ] **Step 1: Write failing library tests** - -```ts -test("skips the template and returns records ordered by id", () => { - assert.deepEqual(getCommunityMeshes().communities.map(({ id }) => id), ["alpha", "beta"]); -}); - -test("fails startup validation for duplicate profile ids and invalid Base64 PSKs", () => { - assert.throws(() => loadCommunityMeshes(fixtureDirectory("invalid")), /community mesh validation failed/); -}); -``` - -- [ ] **Step 2: Implement the loader and semantic checks** - -```ts -const DATA_DIRECTORY = new URL("../../data/communityMeshes/", import.meta.url); -const SCHEMA_PATH = new URL("../../schemas/community-mesh.schema.json", import.meta.url); - -export const loadCommunityMeshes = (directory = DATA_DIRECTORY): CommunityMeshesResponse => { - const communities = readdirSync(directory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.startsWith("_")) - .map((entry) => JSON.parse(readFileSync(new URL(entry.name, directory), "utf8")) as unknown) - .map(validateCommunityMesh) - .sort((left, right) => left.id.localeCompare(right.id)); - - validateUniqueIds(communities); - return Object.freeze({ apiVersion: "v1", schemaVersion: 1, generatedAt: new Date().toISOString(), sourceRevision: process.env.VERCEL_GIT_COMMIT_SHA ?? "local", communities }); -}; -``` - -Define `fixtureDirectory = (name: string) => new URL(`../../tests/fixtures/communityMeshes/${name}/`, import.meta.url);` beside the test imports. Implement `validateCommunityMesh()` with Ajv against the committed schema, then semantic checks for: geometry ring closure and coordinate bounds; profile IDs; optional channel count/order; byte-length channel names; Base64 PSK byte lengths of 0, 1, 16, or 32; licensed profiles with only `null` PSKs; and `overrideFrequencyMHz` only on licensed profiles. Throw one error containing the filename and every validation error. - -- [ ] **Step 3: Add a CLI validator** - -```ts -import { getCommunityMeshes } from "../lib/communityMeshes.js"; - -const registry = getCommunityMeshes(); -console.log(`Validated ${registry.communities.length} community mesh records.`); -``` - -- [ ] **Step 4: Run the focused tests and validator** - -Run: `pnpm test -- communityMeshes.lib.test.ts && pnpm validate:community-meshes` - -Expected: PASS; the command prints `Validated 0 community mesh records.` until a real community record is approved. - -- [ ] **Step 5: Commit the registry library** - -```bash -git add src/lib/communityMeshes.ts src/scripts/validateCommunityMeshes.ts tests/communityMeshes.lib.test.ts -git commit -m "feat: load validated community mesh records" -``` - -### Task 4: Serve cacheable, location-independent registry routes - -**Files:** -- Create: `src/routes/communityMeshes.ts` -- Modify: `src/routes/index.ts` -- Modify: `src/index.ts` -- Modify: `tests/communityMeshes.routes.test.ts` - -**Interfaces:** -- Consumes: `getCommunityMeshes()`, `getCommunityMesh(id)`, and `registryEtag` from Task 3. -- Produces: `CommunityMeshRoutes(app: App): void`. - -- [ ] **Step 1: Extend failing route tests** - -```ts -test("returns 304 when the registry ETag matches", async () => { - const first = await fetch(`${origin}/v1/community-meshes`); - const second = await fetch(`${origin}/v1/community-meshes`, { headers: { "If-None-Match": first.headers.get("etag") ?? "" } }); - assert.equal(second.status, 304); -}); - -test("serves the schema and returns 404 for an unknown community", async () => { - assert.equal((await fetch(`${origin}/v1/community-meshes/schema`)).status, 200); - assert.equal((await fetch(`${origin}/v1/community-meshes/not-found`)).status, 404); -}); -``` - -- [ ] **Step 2: Implement the injected route registrar** - -```ts -export const CommunityMeshRoutes = (app: App): void => { - app.get("/v1/community-meshes", (req, res) => { - const registry = getCommunityMeshes(); - if (req.headers["if-none-match"] === registryEtag) return res.status(304).end(); - res.setHeader("Cache-Control", "public, max-age=3600"); - res.setHeader("ETag", registryEtag); - return res.json(registry); - }); - app.get("/v1/community-meshes/schema", (_req, res) => res.json(getCommunityMeshSchema())); - app.get("/v1/community-meshes/:id", (req, res) => { - const community = getCommunityMesh(req.params.id ?? ""); - return community ? res.json(community) : res.status(404).json({ error: "community_not_found" }); - }); -}; -``` - -- [ ] **Step 3: Register the route without changing legacy route imports** - -```ts -// src/routes/index.ts -export { CommunityMeshRoutes } from "./communityMeshes.js"; - -// src/index.ts -import { CommunityMeshRoutes, /* existing exports */ } from "./routes/index.js"; -CommunityMeshRoutes(app); -``` - -Add `https://client.meshtastic.org` to the existing CORS allowlist so the official web client can fetch the public registry. Do not broaden CORS for unrelated endpoints. - -- [ ] **Step 4: Run focused route tests** - -Run: `pnpm test -- communityMeshes.routes.test.ts` - -Expected: PASS for registry body, ETag 304, schema, and 404 behavior. - -- [ ] **Step 5: Commit routes** - -```bash -git add src/routes/communityMeshes.ts src/routes/index.ts src/index.ts tests/communityMeshes.routes.test.ts -git commit -m "feat: serve community mesh registry" -``` - -### Task 5: Document contribution and automate validation - -**Files:** -- Create: `docs/community-mesh-registry.md` -- Create: `.github/pull_request_template.md` -- Modify: `.github/workflows/ci.yml` -- Modify: `README.md` - -**Interfaces:** -- Consumes: schema, template, and `pnpm validate:community-meshes` from Tasks 2-3. -- Produces: a documented GitHub PR submission and Meshtastic-admin approval process. - -- [ ] **Step 1: Write the failing workflow expectation** - -Add this command locally before changing CI: - -Run: `pnpm validate:community-meshes` - -Expected before Task 3: FAIL because the command does not exist; after Task 3: PASS. - -- [ ] **Step 2: Add contributor instructions** - -```markdown -## Submit a community mesh - -1. Copy `data/communityMeshes/_template.json` to `data/communityMeshes/.json`. -2. Draw a closed GeoJSON Polygon or MultiPolygon in longitude/latitude order. -3. Define one to three distinct RF profiles. Use either a Meshtastic modem preset or all three custom modem values. -4. Omit `mqtt` unless the community needs custom MQTT. Every MQTT value, including `password`, is public when committed. -5. Use `licensed.required: true` only for a no-encryption profile. Clients will collect a callsign locally; never add it to this file. -6. Run `pnpm validate:community-meshes`, `pnpm test`, `pnpm build`, and `pnpm biome ci`. -7. Open a PR and complete the review checklist. -``` - -- [ ] **Step 3: Add reviewer checklist and CI command** - -```yaml -- name: Validate community mesh registry - run: pnpm validate:community-meshes - -- name: Test - run: pnpm test -``` - -The PR template must require confirmation that the submitter is authorized to publish coverage and all public MQTT credentials, that radio settings comply with the stated region/licensed operation, and that links are community-owned. - -- [ ] **Step 4: Run documentation checks** - -Run: `pnpm validate:community-meshes && pnpm test && pnpm build && pnpm biome ci` - -Expected: validator, tests, and build pass. Record pre-existing Biome warnings separately if they remain outside changed files; do not fix unrelated code. - -- [ ] **Step 5: Commit contributor workflow** - -```bash -git add docs/community-mesh-registry.md .github/pull_request_template.md .github/workflows/ci.yml README.md -git commit -m "docs: add community mesh submission workflow" -``` - -### Task 6: Verify the released contract end to end - -**Files:** -- Modify: `README.md` -- Test: `tests/communityMeshes.routes.test.ts` - -**Interfaces:** -- Consumes: every prior task. -- Produces: verified API examples for client teams. - -- [ ] **Step 1: Add a full-contract test fixture and expected response** - -```ts -const payload = await response.json() as { - apiVersion: string; - schemaVersion: number; - generatedAt: string; - sourceRevision: string; - communities: unknown[]; -}; -assert.equal(payload.apiVersion, "v1"); -assert.equal(payload.schemaVersion, 1); -assert.match(payload.generatedAt, /^\d{4}-\d{2}-\d{2}T/); -assert.match(payload.sourceRevision, /^(local|[0-9a-f]{7,64})$/); -assert.deepEqual(payload.communities, []); -``` - -- [ ] **Step 2: Run the full verification suite** - -Run: `pnpm validate:community-meshes && pnpm test && pnpm build && pnpm biome ci` - -Expected: all new validator, unit, and HTTP tests pass; TypeScript build passes; no new Biome diagnostics appear in changed files. - -- [ ] **Step 3: Manually verify no location leaves the client contract** - -Run: `rg -n "lat|lon|location|nearby" src/routes/communityMeshes.ts docs/community-mesh-registry.md README.md` - -Expected: `src/routes/communityMeshes.ts` contains no location-related route parameter or query handling. Documentation may only describe on-device matching. - -- [ ] **Step 4: Commit the final contract verification** - -```bash -git add README.md tests/communityMeshes.routes.test.ts -git commit -m "test: verify community mesh API contract" -``` diff --git a/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md b/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md deleted file mode 100644 index 32e8cf0..0000000 --- a/docs/superpowers/specs/2026-07-13-community-mesh-registry-design.md +++ /dev/null @@ -1,275 +0,0 @@ -# Community Mesh Registry Design - -## Goal - -Publish a reviewed, versioned registry of community Meshtastic meshes at -`api.meshtastic.org`. Client applications download the same public registry, -match an on-device location against community coverage locally, show distinct -mesh profiles, and apply one selected profile after the user reviews the -changes. - -The first deliverable is the registry API and GitHub-based approval workflow in -`meshtastic/api`. Apple, Android, Web, and CLI onboarding integrations are -follow-up projects that consume this stable contract. - -## Scope and ownership - -The registry is public configuration data, stored in the API repository. A -community administrator opens a pull request that changes one community JSON -file. Automated validation checks the submitted record. A Meshtastic -administrator reviews coverage, regulatory suitability, links, and any public -credentials, then merging the pull request approves and publishes the change. - -Version 1 does not add a database, an authenticated write API, a custom admin -portal, or a license-validation service. GitHub history is the approval audit -trail. A future portal may create the same pull request rather than becoming a -second source of truth. - -## Data source layout - -```text -data/communityMeshes/ - _template.json - .json -schemas/ - community-mesh.schema.json -src/lib/ - communityMeshes.ts -src/routes/ - communityMeshes.ts -docs/ - community-mesh-registry.md -.github/ - pull_request_template.md -``` - -Each file contains exactly one `CommunityMesh` record. The stable community ID -is lower-case kebab case and is also the filename. A profile ID is unique within -its community. Keeping records separate minimizes conflicts between unrelated -community submissions. - -## Community record - -```json -{ - "schemaVersion": 1, - "id": "portland-or", - "name": "Portland Mesh", - "description": "Community-operated Meshtastic coverage in Portland.", - "coverage": { - "type": "Polygon", - "coordinates": [[[-122.90, 45.40], [-122.40, 45.40], [-122.40, 45.70], [-122.90, 45.70], [-122.90, 45.40]]] - }, - "links": [ - { "kind": "website", "url": "https://example.org" }, - { "kind": "discord", "url": "https://discord.gg/example" } - ], - "meshProfiles": [] -} -``` - -`coverage` is a GeoJSON `Polygon` or `MultiPolygon` in WGS84 longitude, -latitude order. It represents an administrator's claimed service area, not a -radio-propagation guarantee. Links use a known kind (`website`, `discord`, -`matrix`, `telegram`, `facebook`, `instagram`, `github`, or `other`) and an -HTTPS URL. - -Every community defines one to three selectable `meshProfiles`. A profile is a -separate RF mesh, not a Meshtastic secondary channel. It has independent radio -configuration, a primary channel, optional extra channels, optional MQTT -configuration, and membership requirements. - -## Mesh profile - -```json -{ - "id": "portland-public", - "name": "Portland Public", - "description": "General-purpose community mesh.", - "radio": { - "region": "US", - "modem": { - "kind": "preset", - "preset": "MEDIUM_FAST" - }, - "frequencySlot": 12, - "frequencyOffsetHz": 0, - "maxHops": 3, - "txPowerDbm": 0, - "ignoreMqtt": false, - "sx126xRxBoostedGain": false - }, - "primaryChannel": { - "name": "Portland", - "pskBase64": "AQ==", - "uplinkEnabled": false, - "downlinkEnabled": false - }, - "optionalChannels": [], - "requirements": { - "minimumFirmware": "2.7.0", - "positionIntervalMaxSeconds": 900, - "telemetryIntervalMaxSeconds": 1800 - } -} -``` - -The radio modem is a discriminated union: - -```json -{ - "kind": "custom", - "bandwidthKhz": 250, - "spreadFactor": 11, - "codingRate": 8 -} -``` - -Preset mode specifies a Meshtastic modem preset. Custom mode must specify -bandwidth, spreading factor, and coding rate; it cannot also carry a preset. -`frequencySlot` is the configured LoRa channel number, where `0` retains -firmware's automatic primary-channel-name calculation. `frequencyOffsetHz`, -`maxHops`, `txPowerDbm`, `ignoreMqtt`, and `sx126xRxBoostedGain` map directly -to supported LoRa settings. - -The registry intentionally excludes device-local or unsafe LoRa controls: -transmit disablement, ignored-node lists, PA fan behavior, and duty-cycle -override. It also contains no position-precision setting. Requirements express -the maximum allowed position and telemetry intervals only when a community -needs to state them. - -An optional `overrideFrequencyMHz` is permitted only for a licensed profile. -This avoids distributing an out-of-band override as an ordinary public profile. - -## Channels and licensing - -`primaryChannel` is required. `optionalChannels` is an ordered list the client -offers to the user; it writes accepted channels consecutively after the primary -channel. Each channel has a name, a nullable `pskBase64`, and uplink/downlink -flags. A normal profile may publish a publicly joinable PSK because that is the -community's intended membership configuration. The value must decode to a -Meshtastic-supported PSK length. - -```json -{ - "licensed": { - "required": true - } -} -``` - -A licensed profile contains this object, requires every channel PSK to be -`null`, and may use `overrideFrequencyMHz`. Registry data never contains a -callsign. A client applying such a profile uses fixed product copy, requires -the user to enter a callsign and acknowledge eligibility, sets the callsign as -the device long name, enables `isLicensed`, and removes encryption from every -installed channel. The client does not claim to validate a radio license. - -## MQTT - -MQTT is absent by default. A community includes `mqtt` only when that mesh -requires custom MQTT configuration. It may specify all supported public -connection fields, including the broker address and intentionally public -username/password: - -```json -{ - "mqtt": { - "serverAddress": "mqtt.example.org", - "username": "community-user", - "password": "community-password", - "tlsEnabled": true, - "encryptionEnabled": true, - "jsonEnabled": false, - "rootTopic": "msh/US/Portland", - "proxyToClientEnabled": true, - "mapReportingEnabled": false, - "mapReportingIntervalSeconds": 3600 - } -} -``` - -All MQTT values in this public registry, including a password, are deliberately -public join configuration. Maintainers reject accidental secrets. The -per-channel `uplinkEnabled` and `downlinkEnabled` fields define whether the -profile permits gateway traffic; the MQTT module configuration alone does not -enable channel traffic. - -## API - -The route is versioned independently of existing API routes: - -| Endpoint | Response | -| --- | --- | -| `GET /v1/community-meshes` | The complete active registry, including coverage and public provisioning values. | -| `GET /v1/community-meshes/{id}` | One full community record for direct links or manual browsing. | -| `GET /v1/community-meshes/schema` | The current JSON Schema. | - -Every response includes `apiVersion`, `schemaVersion`, `generatedAt`, and the -source revision. Responses use `ETag`, compression, and public cache headers. -A missing ID returns `404`. The registry never accepts a latitude, longitude, -postal code, device identifier, or other user-specific lookup parameter. - -The complete registry is deliberately small enough to cache locally. Each -client makes the same periodic request regardless of its location, then uses a -bounding-box prefilter followed by an exact local point-in-polygon check against -the GeoJSON coverage. The location never leaves the device and is not persisted -for discovery. A user who declines location permission can pan a local map or -select a community manually. If registry growth makes the full download too -large, clients may retain the full index and offer opt-in country/region packs; -the default privacy-preserving request remains location-independent. - -## Client contract - -Client integrations refresh the common cached registry, find nearby profiles -locally, and show a configuration diff before writing any radio setting. They -must: - -1. Check minimum firmware, region/modem compatibility, and managed-mode status. -2. Let the user accept or decline every optional channel. -3. Prompt for a callsign before applying a licensed profile. -4. Save a local rollback snapshot. -5. Apply user, radio, primary/optional channels, interval requirements, and - optional MQTT configuration. -6. Reconnect after a radio reboot, read every setting back, and report a clear - failure if verification differs from the profile. -7. Restore the snapshot if the application transaction fails before completion. - -Client implementations are separate repository projects because their platform -transport, UI, and rollback facilities differ. They consume this API contract -without defining their own incompatible community data format. - -## Validation, test, and review policy - -The registry validator and tests must enforce: - -- JSON Schema validation for every source record and template. -- Valid Polygon and MultiPolygon rings, coordinate bounds, and non-empty - coverage. -- Unique community and profile IDs, one to three profiles per community, and - valid HTTPS links. -- Exactly one radio modem mode; supported custom modem ranges; valid region, - frequency slot, hops, and transmit-power values. -- Licensed profile encryption rules and the restriction on override frequency. -- Primary-channel name length, optional-channel ordering, valid Base64 PSKs, - and valid MQTT field combinations. -- Semantic minimum firmware versions and positive maximum-interval - requirements. -- On-device point-in-polygon lookup for ordinary polygons, holes, - multipolygons, and boundary cases, with bounding-box prefiltering. -- No location-bearing API route or request parameter. -- `404`, cache, schema, complete-registry, and full-record route behavior. - -The pull-request template requires the submitter to identify the community, -confirm authority to publish the coverage and credentials, describe radio and -licensed-operation legality, and state how maintainers can verify links. CI -runs registry validation, type checking, and formatting. A Meshtastic -administrator is the final approval authority. - -## Out of scope - -- A hosted submission form or self-service account system. -- Secret or per-user credential delivery. -- Radio-license verification. -- RF coverage measurement, propagation prediction, or uptime guarantees. -- Applying profiles directly from this API repository to a radio. From 2188fb3032b6bc708c4be01b88b460b0d69bc488 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:09:22 -0700 Subject: [PATCH 12/13] test: avoid returning from fixture writer --- tests/communityMeshes.schema.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts index ac473ca..717d8a8 100644 --- a/tests/communityMeshes.schema.test.ts +++ b/tests/communityMeshes.schema.test.ts @@ -39,9 +39,9 @@ const withRecords = ( ): void => { const directory = mkdtempSync(join(tmpdir(), "community-mesh-test-")); try { - records.forEach((record, index) => - writeFileSync(join(directory, `${index}.json`), JSON.stringify(record)), - ); + records.forEach((record, index) => { + writeFileSync(join(directory, `${index}.json`), JSON.stringify(record)); + }); callback(pathToFileURL(`${directory}/`)); } finally { rmSync(directory, { force: true, recursive: true }); From 25ea1573b7076820cc4ffe8ce63e954eb6f90264 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:15:50 -0700 Subject: [PATCH 13/13] fix: address community registry cache review --- src/lib/communityMeshes.ts | 18 +++++++++++++++--- src/routes/communityMeshes.ts | 18 +++++++++++++++--- tests/communityMeshes.routes.test.ts | 25 +++++++++++++++++++++++++ tests/communityMeshes.schema.test.ts | 14 ++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/lib/communityMeshes.ts b/src/lib/communityMeshes.ts index a9a894c..72c710d 100644 --- a/src/lib/communityMeshes.ts +++ b/src/lib/communityMeshes.ts @@ -26,6 +26,16 @@ export interface CommunityMeshesResponse { 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; @@ -123,11 +133,11 @@ export const loadCommunityMeshes = ( throw new Error(`duplicate community id: ${communities[index].id}`); } } - return communities; + return deepFreeze(communities); }; const communities = loadCommunityMeshes(); -const response: CommunityMeshesResponse = Object.freeze({ +const response: CommunityMeshesResponse = deepFreeze({ apiVersion: "v1", schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -135,8 +145,10 @@ const response: CommunityMeshesResponse = Object.freeze({ communities, }); +export const registryJson = JSON.stringify(response, null, 2); + export const registryEtag = `"${createHash("sha256") - .update(JSON.stringify(communities)) + .update(registryJson) .digest("base64url")}"`; export const getCommunityMeshes = (): CommunityMeshesResponse => response; diff --git a/src/routes/communityMeshes.ts b/src/routes/communityMeshes.ts index 5baeeb3..7d66cec 100644 --- a/src/routes/communityMeshes.ts +++ b/src/routes/communityMeshes.ts @@ -1,19 +1,31 @@ import type { App } from "@tinyhttp/app"; import { getCommunityMesh, - getCommunityMeshes, 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 (req.headers["if-none-match"] === registryEtag) { + if (ifNoneMatchMatches(req.headers["if-none-match"], registryEtag)) { return res.status(304).end(); } - return res.json(getCommunityMeshes()); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + return res.send(registryJson); }); app.get("/v1/community-meshes/schema", (_req, res) => { diff --git a/tests/communityMeshes.routes.test.ts b/tests/communityMeshes.routes.test.ts index 8bd9827..c912333 100644 --- a/tests/communityMeshes.routes.test.ts +++ b/tests/communityMeshes.routes.test.ts @@ -1,4 +1,5 @@ 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"; @@ -30,6 +31,11 @@ test("registry uses ETags for cache revalidation", async () => { 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 }, @@ -39,6 +45,25 @@ test("registry uses ETags for cache revalidation", async () => { }); }); +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); diff --git a/tests/communityMeshes.schema.test.ts b/tests/communityMeshes.schema.test.ts index 717d8a8..9701542 100644 --- a/tests/communityMeshes.schema.test.ts +++ b/tests/communityMeshes.schema.test.ts @@ -52,6 +52,20 @@ 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); });