diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f47a9ea..a2b9379b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -37,5 +37,17 @@ "statusBarItem.remoteForeground": "#e7e7e7" }, "peacock.color": "#557c9b", - "git.ignoreLimitWarning": true + "git.ignoreLimitWarning": true, + "cSpell.words": [ + "aprs", + "bircom", + "Flymaster", + "flyme", + "imei", + "inreach", + "meshbir", + "Meshtastic", + "xcontest", + "Zoleo" + ] } diff --git a/apps/fetcher/src/app/state/sync.test.ts b/apps/fetcher/src/app/state/sync.test.ts index f2813fec..959660a6 100644 --- a/apps/fetcher/src/app/state/sync.test.ts +++ b/apps/fetcher/src/app/state/sync.test.ts @@ -17,6 +17,7 @@ const FLYMASTER = '003'; const OGN = `123456`; const ZOLEO = `012345678912345`; const XCONTEST = `a123456789012345678901234567`; +const MESHBIR = `12345678-1234-1234-1234-123456789012`; describe('sync', () => { let nowFn: any; @@ -63,6 +64,7 @@ describe('sync', () => { ogn: OGN, zoleo: ZOLEO, xcontest: XCONTEST, + meshbir: MESHBIR, }; for (const [name, account] of Object.entries(trackerAccounts)) { @@ -210,6 +212,7 @@ describe('sync', () => { ogn: createTrackerEntity(OGN), zoleo: createTrackerEntity(ZOLEO, { type: 'zoleo' }), xcontest: createTrackerEntity(XCONTEST), + meshbir: createTrackerEntity(MESHBIR), }); syncLiveTrack(state, lt); @@ -221,6 +224,7 @@ describe('sync', () => { expect(state.pilots[1978].ogn.enabled).toEqual(true); expect(state.pilots[1978].zoleo.enabled).toEqual(true); expect(state.pilots[1978].xcontest.enabled).toEqual(true); + expect(state.pilots[1978].meshbir.enabled).toEqual(true); expect(state.pilots[1978].inreach.account).toEqual(INREACH); expect(state.pilots[1978].spot.account).toEqual(SPOT); @@ -230,6 +234,7 @@ describe('sync', () => { expect(state.pilots[1978].ogn.account).toEqual(OGN); expect(state.pilots[1978].zoleo.account).toEqual(ZOLEO); expect(state.pilots[1978].xcontest.account).toEqual(XCONTEST); + expect(state.pilots[1978].meshbir.account).toEqual(MESHBIR); lt = createLiveTrackEntity('1978', { inreach: createTrackerEntity('invalid'), @@ -240,6 +245,7 @@ describe('sync', () => { ogn: createTrackerEntity('invalid'), zoleo: createTrackerEntity('invalid', { type: 'zoleo' }), xcontest: createTrackerEntity('invalid'), + meshbir: createTrackerEntity('invalid'), }); syncLiveTrack(state, lt); @@ -251,6 +257,7 @@ describe('sync', () => { expect(state.pilots[1978].ogn.enabled).toEqual(false); expect(state.pilots[1978].zoleo.enabled).toEqual(false); expect(state.pilots[1978].xcontest.enabled).toEqual(false); + expect(state.pilots[1978].meshbir.enabled).toEqual(false); expect(state.pilots[1978].inreach.account).toEqual(''); expect(state.pilots[1978].spot.account).toEqual(''); @@ -260,6 +267,7 @@ describe('sync', () => { expect(state.pilots[1978].ogn.account).toEqual(''); expect(state.pilots[1978].zoleo.account).toEqual(''); expect(state.pilots[1978].xcontest.account).toEqual(''); + expect(state.pilots[1978].meshbir.account).toEqual(''); }); }); }); @@ -282,6 +290,7 @@ function createLiveTrackEntity(id: string, liveTrack: Partial = ogn: createTrackerEntity(OGN), zoleo: createTrackerEntity(ZOLEO, { type: 'zoleo' }), xcontest: createTrackerEntity(XCONTEST), + meshbir: createTrackerEntity(MESHBIR), }; return { ...entity, ...liveTrack }; diff --git a/apps/fetcher/src/app/state/sync.ts b/apps/fetcher/src/app/state/sync.ts index 8e148903..4508bc3d 100644 --- a/apps/fetcher/src/app/state/sync.ts +++ b/apps/fetcher/src/app/state/sync.ts @@ -174,6 +174,7 @@ function createPilotFromEntity(liveTrack: LiveTrackEntity): protos.Pilot { ...createAccountEnabledTracker('ogn', liveTrack), zoleo, ...createAccountEnabledTracker('xcontest', liveTrack), + ...createAccountEnabledTracker('meshbir', liveTrack), }; } diff --git a/apps/fetcher/src/app/trackers/meshbir.test.ts b/apps/fetcher/src/app/trackers/meshbir.test.ts new file mode 100644 index 00000000..6a535d8d --- /dev/null +++ b/apps/fetcher/src/app/trackers/meshbir.test.ts @@ -0,0 +1,66 @@ +import { parse } from './meshbir'; + +describe('parse', () => { + describe('position', () => { + it('should translate message to points', () => { + expect( + parse([ + { + altitude: 1778.12, + ground_speed: 30, + latitude: 32.1927, + longitude: 76.4506, + time: 123460, + type: 'position', + user_id: '12345678-1234-1234-1234-123456789012', + }, + { + altitude: 1778.12, + ground_speed: 30, + latitude: 32.1927, + longitude: 76.4506, + time: 123450, + type: 'position', + user_id: '12345678-1234-1234-1234-123456789012', + }, + ]), + ).toMatchInlineSnapshot(` + Map { + "12345678-1234-1234-1234-123456789012" => [ + { + "alt": 1778.12, + "lat": 32.1927, + "lon": 76.4506, + "name": "meshbir", + "speed": 30, + "timeMs": 123460, + }, + { + "alt": 1778.12, + "lat": 32.1927, + "lon": 76.4506, + "name": "meshbir", + "speed": 30, + "timeMs": 123450, + }, + ], + } + `); + }); + }); + + describe('test', () => { + it('should silently ignore messages', () => { + expect( + parse([ + { + type: 'message', + user_id: '12345678-1234-1234-1234-123456789012', + time: 123456, + message: 'hello Meshtastic', + }, + ]), + ).toEqual(new Map()); + }); + }); +}); diff --git a/apps/fetcher/src/app/trackers/meshbir.ts b/apps/fetcher/src/app/trackers/meshbir.ts new file mode 100644 index 00000000..7e88dcf0 --- /dev/null +++ b/apps/fetcher/src/app/trackers/meshbir.ts @@ -0,0 +1,107 @@ +// https://bircom.in/ +// https://github.com/vicb/flyXC/issues/301 + +import type { protos, TrackerNames } from '@flyxc/common'; +import { Keys, removeBeforeFromLiveTrack, validateMeshBirAccount } from '@flyxc/common'; +import type { MeshBirMessage } from '@flyxc/common-node'; +import type { ChainableCommander, Redis } from 'ioredis'; + +import type { LivePoint } from './live-track'; +import { makeLiveTrack } from './live-track'; +import type { TrackerUpdates } from './tracker'; +import { TrackerFetcher } from './tracker'; + +const KEEP_HISTORY_MIN = 20; + +export class MeshBirFetcher extends TrackerFetcher { + constructor(state: protos.FetcherState, pipeline: ChainableCommander, protected redis: Redis) { + super(state, pipeline); + } + + protected getTrackerName(): TrackerNames { + return 'meshbir'; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async fetch(devices: number[], updates: TrackerUpdates, timeoutSec: number): Promise { + const messages = (await flushMessageQueue(this.redis)).filter((m) => m != null); + + if (messages.length == 0) { + return; + } + + // Maps meshbir ids to datastore ids. + const meshIdToDsId = new Map(); + for (const dsId of devices) { + const tracker = this.getTracker(dsId); + if (tracker == null) { + updates.trackerErrors.set(dsId, `Not found ${tracker.account}`); + continue; + } + if (validateMeshBirAccount(tracker.account) === false) { + updates.trackerErrors.set(dsId, `Invalid account ${tracker.account}`); + continue; + } + meshIdToDsId.set(tracker.account, dsId); + } + + const pointsByMeshId = parse(messages); + + for (const [meshId, points] of pointsByMeshId.entries()) { + const dsId = meshIdToDsId.get(meshId); + if (dsId != null) { + const liveTrack = removeBeforeFromLiveTrack( + makeLiveTrack(points), + Math.round(Date.now() / 1000) - KEEP_HISTORY_MIN * 60, + ); + if (liveTrack.timeSec.length > 0) { + updates.trackerDeltas.set(dsId, liveTrack); + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldFetch(tracker: protos.Tracker) { + return true; + } +} + +export function parse(messages: MeshBirMessage[]): Map { + const pointsByMeshId = new Map(); + for (const msg of messages) { + if (msg.type == 'position') { + const point: LivePoint = { + lat: msg.latitude, + lon: msg.longitude, + alt: msg.altitude, + speed: msg.ground_speed, + timeMs: msg.time, + name: 'meshbir', + }; + const meshId = validateMeshBirAccount(msg.user_id); + if (meshId !== false) { + const points = pointsByMeshId.get(meshId) ?? []; + points.push(point); + pointsByMeshId.set(meshId, points); + } + } + } + return pointsByMeshId; +} + +// Returns and empty the message queue. +async function flushMessageQueue(redis: Redis): Promise { + try { + const [[_, messages]] = await redis + .multi() + .lrange(Keys.meshBirMsgQueue, 0, -1) + .ltrim(Keys.meshBirMsgQueue, 1, 0) + .exec(); + + // Return older messages first + return (messages as string[]).map((json) => JSON.parse(json)).reverse(); + } catch (e) { + console.error('Error reading meshbir queue', e); + } +} diff --git a/apps/fetcher/src/app/trackers/refresh.ts b/apps/fetcher/src/app/trackers/refresh.ts index ae7195a7..2ef61098 100644 --- a/apps/fetcher/src/app/trackers/refresh.ts +++ b/apps/fetcher/src/app/trackers/refresh.ts @@ -18,6 +18,7 @@ import { addElevationLogs } from '../redis'; import { FlymasterFetcher } from './flymaster'; import { FlymeFetcher } from './flyme'; import { InreachFetcher } from './inreach'; +import { MeshBirFetcher } from './meshbir'; import { OgnFetcher } from './ogn'; import { OGN_HOST, OGN_PORT, OgnClient } from './ogn-client'; import { SkylinesFetcher } from './skylines'; @@ -60,6 +61,7 @@ export async function resfreshTrackers( new OgnFetcher(ognClient, state, pipeline), new ZoleoFetcher(state, pipeline, redis, datastore), new XcontestFetcher(state, pipeline), + new MeshBirFetcher(state, pipeline, redis), ]; const fetchResults = await Promise.allSettled(fetchers.map((f) => f.refresh(LIVE_FETCH_TIMEOUT_SEC))); diff --git a/apps/fetcher/src/app/trackers/zoleo.ts b/apps/fetcher/src/app/trackers/zoleo.ts index 58162fc4..c3f927cc 100644 --- a/apps/fetcher/src/app/trackers/zoleo.ts +++ b/apps/fetcher/src/app/trackers/zoleo.ts @@ -127,7 +127,7 @@ async function addNewDevices(datastore: Datastore, messages: ZoleoMessage[]): Pr return addedDevices; } -// Reand and empty the message queue. +// Returns and empty the message queue. async function flushMessageQueue(redis: Redis): Promise<(ZoleoMessage | null)[]> { try { const [[_, messages]] = await redis diff --git a/apps/fxc-front/src/app/pages/settings.ts b/apps/fxc-front/src/app/pages/settings.ts index 5e3dd440..fa9bb08f 100644 --- a/apps/fxc-front/src/app/pages/settings.ts +++ b/apps/fxc-front/src/app/pages/settings.ts @@ -265,7 +265,18 @@ export class SettingsPage extends LitElement { > - `, + + + Enter your Meshtastic UUID. It should look like "12345678-ab45-b23c-8549-1f3456c89e12". + `} + > + + + `, )} @@ -489,6 +500,7 @@ export class SettingsPage extends LitElement { OGN (Open Glider Network)
  • XCTrack (XContest live)
  • +
  • Meshtastic (Bircom)
  • Contact us by email diff --git a/apps/fxc-server/src/app/routes/meshbir.test.ts b/apps/fxc-server/src/app/routes/meshbir.test.ts new file mode 100644 index 00000000..32a5d32a --- /dev/null +++ b/apps/fxc-server/src/app/routes/meshbir.test.ts @@ -0,0 +1,49 @@ +import { parseMessage } from './meshbir'; + +describe('parseMessage', () => { + it('should parse a position', () => { + expect( + parseMessage({ + type: 'position', + user_id: '12345678-1234-1234-1234-123456789012', + time: 123456, + latitude: 32.1927, + longitude: 76.4506, + altitude: 1778.12, + ground_speed: 30, + }), + ).toMatchInlineSnapshot(` + { + "altitude": 1778.12, + "ground_speed": 30, + "latitude": 32.1927, + "longitude": 76.4506, + "time": 123456, + "type": "position", + "user_id": "12345678-1234-1234-1234-123456789012", + } + `); + }); + + it('should parse a test', () => { + expect( + parseMessage({ + type: 'message', + user_id: '12345678-1234-1234-1234-123456789012', + time: 123456, + message: 'hello Meshtastic', + }), + ).toMatchInlineSnapshot(` + { + "message": "hello Meshtastic", + "time": 123456, + "type": "message", + "user_id": "12345678-1234-1234-1234-123456789012", + } + `); + }); + + it('should throw on invalid message', () => { + expect(() => parseMessage({ type: 'unkown' })).toThrow(); + }); +}); diff --git a/apps/fxc-server/src/app/routes/meshbir.ts b/apps/fxc-server/src/app/routes/meshbir.ts new file mode 100644 index 00000000..c010e704 --- /dev/null +++ b/apps/fxc-server/src/app/routes/meshbir.ts @@ -0,0 +1,38 @@ +import { Keys } from '@flyxc/common'; +import type { MeshBirMessage } from '@flyxc/common-node'; +import { MESHBIR_MAX_MSG, MESHBIR_MAX_MSG_SIZE, positionSchema, pushListCap, textSchema } from '@flyxc/common-node'; +import { Secrets } from '@flyxc/secrets'; +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import type Redis from 'ioredis'; + +export function getMeshBirRouter(redis: Redis): Router { + const router = Router(); + + // Hook called by meshbir. + router.post('/push', async (req: Request, res: Response) => { + const [bearer, value] = req.headers.authorization.split(' '); + if (bearer.toLowerCase() !== 'bearer' || value !== Secrets.MESHBIR_AUTH_TOKEN) { + return res.sendStatus(403); + } + + try { + const message = parseMessage(req.body); + const pipeline = redis.pipeline(); + pushListCap(pipeline, Keys.meshBirMsgQueue, [JSON.stringify(message)], MESHBIR_MAX_MSG, MESHBIR_MAX_MSG_SIZE); + await pipeline.exec(); + res.sendStatus(200); + } catch (e) { + console.error(e); + res.sendStatus(500); + } + }); + + return router; +} + +// Parses meshbir messages. +// Throws when the message is invalid. +export function parseMessage(message: unknown): MeshBirMessage | undefined { + return textSchema.or(positionSchema).parse(message); +} diff --git a/apps/fxc-server/src/main.ts b/apps/fxc-server/src/main.ts index 8704817f..eec80ed3 100644 --- a/apps/fxc-server/src/main.ts +++ b/apps/fxc-server/src/main.ts @@ -9,6 +9,7 @@ import session from 'express-session'; import { getAdminRouter } from './app/routes/admin'; import { getTrackerRouter } from './app/routes/live-track'; +import { getMeshBirRouter } from './app/routes/meshbir'; import { getSupportersRouter } from './app/routes/supporters'; import { getTrackRouter } from './app/routes/track'; import { getWaypointRouter } from './app/routes/waypoints'; @@ -92,6 +93,7 @@ const app = express() .use('/api/track', getTrackRouter(datastore)) .use('/api/waypoint', getWaypointRouter()) .use('/api/zoleo', getZoleoRouter(redis)) + .use('/api/bircom', getMeshBirRouter(redis)) .use('/api', getSupportersRouter(redis)); const port = process.env.PORT || 8080; diff --git a/libs/common-node/src/index.ts b/libs/common-node/src/index.ts index 3cbae539..7a58e185 100644 --- a/libs/common-node/src/index.ts +++ b/libs/common-node/src/index.ts @@ -1,5 +1,6 @@ export { getDatastore } from './lib/datastore'; export * from './lib/live-track-entity'; +export { MESHBIR_MAX_MSG, MESHBIR_MAX_MSG_SIZE, MeshBirMessage, positionSchema, textSchema } from './lib/meshtbir'; export * from './lib/redis'; export { queueTrackPostProcessing } from './lib/track'; export * from './lib/track-entity'; diff --git a/libs/common-node/src/lib/meshtbir.ts b/libs/common-node/src/lib/meshtbir.ts new file mode 100644 index 00000000..09e41144 --- /dev/null +++ b/libs/common-node/src/lib/meshtbir.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const MESHBIR_MAX_MSG_SIZE = 250; +// Consume at max 1MB of memory +export const MESHBIR_MAX_MSG = Math.floor(1e6 / MESHBIR_MAX_MSG_SIZE); + +export const positionSchema = z + .object({ + type: z.literal('position'), + user_id: z.string().uuid(), + latitude: z.number(), + longitude: z.number(), + altitude: z.number(), + time: z.number().min(0), + ground_speed: z.number(), + }) + .required(); + +export const textSchema = z + .object({ + type: z.literal('message'), + user_id: z.string().uuid(), + time: z.number(), + message: z.string(), + }) + .required(); + +export type Position = z.infer; +export type Message = z.infer; +export type MeshBirMessage = Position | Message; diff --git a/libs/common/src/lib/live-track-entity.ts b/libs/common/src/lib/live-track-entity.ts index 7b8ee4ba..167461f5 100644 --- a/libs/common/src/lib/live-track-entity.ts +++ b/libs/common/src/lib/live-track-entity.ts @@ -32,4 +32,5 @@ export interface LiveTrackEntity { ogn?: TrackerEntity; zoleo?: TrackerEntity; xcontest?: TrackerEntity; + meshbir?: TrackerEntity; } diff --git a/libs/common/src/lib/live-track.ts b/libs/common/src/lib/live-track.ts index 0bef7392..63bd71f8 100644 --- a/libs/common/src/lib/live-track.ts +++ b/libs/common/src/lib/live-track.ts @@ -50,7 +50,17 @@ export const TRACK_GAP_MIN = 60; // Export to partners. export const EXPORT_UPDATE_SEC = 5 * 60; -export const trackerNames = ['inreach', 'spot', 'skylines', 'flyme', 'flymaster', 'ogn', 'zoleo', 'xcontest'] as const; +export const trackerNames = [ + 'inreach', + 'spot', + 'skylines', + 'flyme', + 'flymaster', + 'ogn', + 'zoleo', + 'xcontest', + 'meshbir', +] as const; if (trackerNames.length > MAX_NUM_DEVICES - 1) { throw new Error('Too many devices'); @@ -69,6 +79,7 @@ export const trackerDisplayNames: Readonly> = { ogn: 'OGN', zoleo: 'zoleo', xcontest: 'XContest', + meshbir: 'Meshtastic', }; export const trackerIdByName: Record = {} as any; diff --git a/libs/common/src/lib/models.test.ts b/libs/common/src/lib/models.test.ts index 703912ed..529834b8 100644 --- a/libs/common/src/lib/models.test.ts +++ b/libs/common/src/lib/models.test.ts @@ -1,6 +1,7 @@ import { validateFlymasterAccount, validateInreachAccount, + validateMeshBirAccount, validateOgnAccount, validateSkylinesAccount, validateSpotAccount, @@ -164,3 +165,20 @@ describe('Validate xcontest accounts', () => { expect(validateXContestAccount(' UgsHrVb3TSwVp23o9P1LsEaIWPZ ')).toEqual(false); }); }); + +describe('Validate meshbir accounts', () => { + test('Valid ids', () => { + expect(validateMeshBirAccount('aa752cea-8222-5bc8-acd9-123b090c0ccb')).toBe('AA752CEA-8222-5BC8-ACD9-123B090C0CCB'); + expect(validateMeshBirAccount('aa752cEa-8222-5Bc8-acd9-123B090c0ccb')).toBe('AA752CEA-8222-5BC8-ACD9-123B090C0CCB'); + expect(validateMeshBirAccount(' aa752cea-8222-5bc8-acd9-123b090c0ccb ')).toBe( + 'AA752CEA-8222-5BC8-ACD9-123B090C0CCB', + ); + }); + + test('Invalid ids', () => { + expect(validateMeshBirAccount('')).toEqual(false); + expect(validateMeshBirAccount('0123456789012345678901234567')).toEqual(false); + expect(validateMeshBirAccount('UgsHrVb3TSwVp23o9P1LsEaIWPZ')).toEqual(false); + expect(validateMeshBirAccount(' UgsHrVb3TSwVp23o9P1LsEaIWPZ ')).toEqual(false); + }); +}); diff --git a/libs/common/src/lib/models.ts b/libs/common/src/lib/models.ts index f76e584a..177513a6 100644 --- a/libs/common/src/lib/models.ts +++ b/libs/common/src/lib/models.ts @@ -20,6 +20,7 @@ export interface AccountModel { ogn: TrackerModel; zoleo: TrackerModel; xcontest: TrackerModel; + meshbir: TrackerModel; } // Form model for a client side account. @@ -37,6 +38,7 @@ export class AccountFormModel extends ObjectModel { ogn: TrackerFormModel.createEmptyValue(), zoleo: TrackerFormModel.createEmptyValue(), xcontest: TrackerFormModel.createEmptyValue(), + meshbir: TrackerFormModel.createEmptyValue(), }; } @@ -79,6 +81,7 @@ export class AccountFormModel extends ObjectModel { readonly ogn = this.createTrackerModel('ogn'); readonly zoleo = this.createTrackerModel('zoleo'); readonly xcontest = this.createTrackerModel('xcontest'); + readonly meshbir = this.createTrackerModel('meshbir'); private createTrackerModel(tracker: TrackerNames): TrackerFormModel { const validators = trackerValidators[tracker]; @@ -131,6 +134,7 @@ export const trackerValidators: Readonly { { no: 10, name: 'ogn', kind: 'message', T: () => Tracker }, { no: 11, name: 'zoleo', kind: 'message', T: () => Tracker }, { no: 12, name: 'xcontest', kind: 'message', T: () => Tracker }, + { no: 13, name: 'meshbir', kind: 'message', T: () => Tracker }, ]); } } diff --git a/libs/secrets/.env b/libs/secrets/.env index 268f956f..66c5ce70 100644 --- a/libs/secrets/.env +++ b/libs/secrets/.env @@ -19,4 +19,5 @@ XCONTEXT_JWT = "..." BUY_ME_A_COFFEE_TOKEN = "..." FLYMASTER_GROUP_ID = "..." FLYMASTER_GROUP_TOKEN = "..." -MAILERSEND_TOKEN = "..." \ No newline at end of file +MAILERSEND_TOKEN = "..." +MESHBIR_AUTH_TOKEN = "..." \ No newline at end of file diff --git a/libs/secrets/src/lib/secrets.ts b/libs/secrets/src/lib/secrets.ts index d8435565..22b1ab99 100644 --- a/libs/secrets/src/lib/secrets.ts +++ b/libs/secrets/src/lib/secrets.ts @@ -22,4 +22,5 @@ export const Secrets = { FLYMASTER_GROUP_ID: String(process.env['FLYMASTER_GROUP_ID']), FLYMASTER_GROUP_TOKEN: String(process.env['FLYMASTER_GROUP_TOKEN']), MAILERSEND_TOKEN: String(process.env['MAILERSEND_TOKEN']), + MESHBIR_AUTH_TOKEN: String(process.env['MESHBIR_AUTH_TOKEN']), }; diff --git a/package-lock.json b/package-lock.json index 1aa5e022..fa2c9a30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,8 @@ "unzipper": "^0.12.3", "validator": "^13.12.0", "xml-js": "^1.6.11", - "xmlbuilder": "^15.1.1" + "xmlbuilder": "^15.1.1", + "zod": "^3.23.8" }, "devDependencies": { "@esri/arcgis-rest-geocoding": "^4.0.3", @@ -34779,7 +34780,6 @@ "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 1dbba13a..e6c76cad 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "unzipper": "^0.12.3", "validator": "^13.12.0", "xml-js": "^1.6.11", - "xmlbuilder": "^15.1.1" + "xmlbuilder": "^15.1.1", + "zod": "^3.23.8" } }