8 Commits

Author SHA1 Message Date
ec8667992d fix(deps): update dependency vue-router to v5
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
2026-02-22 20:47:23 +00:00
CI
7398bb0a16 release v1.0.2 [skip ci] 2026-02-22 20:44:40 +00:00
79d36df83c chore(deps): update dependency eslint to v10 (#10)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`^9.0.0` → `^10.0.0`](https://renovatebot.com/diffs/npm/eslint/9.39.2/10.0.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.0.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/9.39.2/10.0.1?slim=true) |

---

### Release Notes

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.0.1`](https://github.com/eslint/eslint/releases/tag/v10.0.1)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.0...v10.0.1)

#### Bug Fixes

- [`c87d5bd`](c87d5bded5) fix: update eslint ([#&#8203;20531](https://github.com/eslint/eslint/issues/20531)) (renovate\[bot])
- [`d841001`](d84100115c) fix: update `minimatch` to `10.2.1` to address security vulnerabilities ([#&#8203;20519](https://github.com/eslint/eslint/issues/20519)) (루밀LuMir)
- [`04c2147`](04c21475b3) fix: update error message for unused suppressions ([#&#8203;20496](https://github.com/eslint/eslint/issues/20496)) (fnx)
- [`38b089c`](38b089c172) fix: update dependency [@&#8203;eslint/config-array](https://github.com/eslint/config-array) to ^0.23.1 ([#&#8203;20484](https://github.com/eslint/eslint/issues/20484)) (renovate\[bot])

#### Documentation

- [`5b3dbce`](5b3dbce50a) docs: add AI acknowledgement section to templates ([#&#8203;20431](https://github.com/eslint/eslint/issues/20431)) (루밀LuMir)
- [`6f23076`](6f23076037) docs: toggle nav in no-JS mode ([#&#8203;20476](https://github.com/eslint/eslint/issues/20476)) (Tanuj Kanti)
- [`b69cfb3`](b69cfb32a1) docs: Update README (GitHub Actions Bot)

#### Chores

- [`e5c281f`](e5c281ffd0) chore: updates for v9.39.3 release (Jenkins)
- [`8c3832a`](8c3832adb7) chore: update [@&#8203;typescript-eslint/parser](https://github.com/typescript-eslint/parser) to ^8.56.0 ([#&#8203;20514](https://github.com/eslint/eslint/issues/20514)) (Milos Djermanovic)
- [`8330d23`](8330d238ae) test: add tests for config-api ([#&#8203;20493](https://github.com/eslint/eslint/issues/20493)) (Milos Djermanovic)
- [`37d6e91`](37d6e91e88) chore: remove eslint v10 prereleases from eslint-config-eslint deps ([#&#8203;20494](https://github.com/eslint/eslint/issues/20494)) (Milos Djermanovic)
- [`da7cd0e`](da7cd0e791) refactor: cleanup error message templates ([#&#8203;20479](https://github.com/eslint/eslint/issues/20479)) (Francesco Trotta)
- [`84fb885`](84fb885d49) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`1f66734`](1f667344b5) chore: add `eslint` to `peerDependencies` of `@eslint/js` ([#&#8203;20467](https://github.com/eslint/eslint/issues/20467)) (Milos Djermanovic)

### [`v10.0.0`](https://github.com/eslint/eslint/releases/tag/v10.0.0)

[Compare Source](https://github.com/eslint/eslint/compare/v9.39.3...v10.0.0)

#### Breaking Changes

- [`f9e54f4`](f9e54f43a5) feat!: estimate rule-tester failure location ([#&#8203;20420](https://github.com/eslint/eslint/issues/20420)) (ST-DDT)
- [`a176319`](a176319d8a) feat!: replace `chalk` with `styleText` and add `color` to `ResultsMeta` ([#&#8203;20227](https://github.com/eslint/eslint/issues/20227)) (루밀LuMir)
- [`c7046e6`](c7046e6c1e) feat!: enable JSX reference tracking ([#&#8203;20152](https://github.com/eslint/eslint/issues/20152)) (Pixel998)
- [`fa31a60`](fa31a60890) feat!: add `name` to configs ([#&#8203;20015](https://github.com/eslint/eslint/issues/20015)) (Kirk Waiblinger)
- [`3383e7e`](3383e7ec90) fix!: remove deprecated `SourceCode` methods ([#&#8203;20137](https://github.com/eslint/eslint/issues/20137)) (Pixel998)
- [`501abd0`](501abd0e91) feat!: update dependency minimatch to v10 ([#&#8203;20246](https://github.com/eslint/eslint/issues/20246)) (renovate\[bot])
- [`ca4d3b4`](ca4d3b4008) fix!: stricter rule tester assertions for valid test cases ([#&#8203;20125](https://github.com/eslint/eslint/issues/20125)) (唯然)
- [`96512a6`](96512a66c8) fix!: Remove deprecated rule context methods ([#&#8203;20086](https://github.com/eslint/eslint/issues/20086)) (Nicholas C. Zakas)
- [`c69fdac`](c69fdacdb2) feat!: remove eslintrc support ([#&#8203;20037](https://github.com/eslint/eslint/issues/20037)) (Francesco Trotta)
- [`208b5cc`](208b5cc34a) feat!: Use `ScopeManager#addGlobals()` ([#&#8203;20132](https://github.com/eslint/eslint/issues/20132)) (Milos Djermanovic)
- [`a2ee188`](a2ee188ea7) fix!: add `uniqueItems: true` in `no-invalid-regexp` option ([#&#8203;20155](https://github.com/eslint/eslint/issues/20155)) (Tanuj Kanti)
- [`a89059d`](a89059dbf2) feat!: Program range span entire source text ([#&#8203;20133](https://github.com/eslint/eslint/issues/20133)) (Pixel998)
- [`39a6424`](39a6424373) fix!: assert 'text' is a string across all RuleFixer methods ([#&#8203;20082](https://github.com/eslint/eslint/issues/20082)) (Pixel998)
- [`f28fbf8`](f28fbf8462) fix!: Deprecate `"always"` and `"as-needed"` options of the `radix` rule ([#&#8203;20223](https://github.com/eslint/eslint/issues/20223)) (Milos Djermanovic)
- [`aa3fb2b`](aa3fb2b233) fix!: tighten `func-names` schema ([#&#8203;20119](https://github.com/eslint/eslint/issues/20119)) (Pixel998)
- [`f6c0ed0`](f6c0ed0311) feat!: report `eslint-env` comments as errors ([#&#8203;20128](https://github.com/eslint/eslint/issues/20128)) (Francesco Trotta)
- [`4bf739f`](4bf739fb53) fix!: remove deprecated `LintMessage#nodeType` and `TestCaseError#type` ([#&#8203;20096](https://github.com/eslint/eslint/issues/20096)) (Pixel998)
- [`523c076`](523c076866) feat!: drop support for jiti < 2.2.0 ([#&#8203;20016](https://github.com/eslint/eslint/issues/20016)) (michael faith)
- [`454a292`](454a292c95) feat!: update `eslint:recommended` configuration ([#&#8203;20210](https://github.com/eslint/eslint/issues/20210)) (Pixel998)
- [`4f880ee`](4f880ee029) feat!: remove `v10_*` and inactive `unstable_*` flags ([#&#8203;20225](https://github.com/eslint/eslint/issues/20225)) (sethamus)
- [`f18115c`](f18115c363) feat!: `no-shadow-restricted-names` report `globalThis` by default ([#&#8203;20027](https://github.com/eslint/eslint/issues/20027)) (sethamus)
- [`c6358c3`](c6358c31fb) feat!: Require Node.js `^20.19.0 || ^22.13.0 || >=24` ([#&#8203;20160](https://github.com/eslint/eslint/issues/20160)) (Milos Djermanovic)

#### Features

- [`bff9091`](bff9091927) feat: handle `Array.fromAsync` in `array-callback-return` ([#&#8203;20457](https://github.com/eslint/eslint/issues/20457)) (Francesco Trotta)
- [`290c594`](290c594bb5) feat: add `self` to `no-implied-eval` rule ([#&#8203;20468](https://github.com/eslint/eslint/issues/20468)) (sethamus)
- [`43677de`](43677de07e) feat: fix handling of function and class expression names in `no-shadow` ([#&#8203;20432](https://github.com/eslint/eslint/issues/20432)) (Milos Djermanovic)
- [`f0cafe5`](f0cafe5f37) feat: rule tester add assertion option `requireData` ([#&#8203;20409](https://github.com/eslint/eslint/issues/20409)) (fnx)
- [`f7ab693`](f7ab6937e6) feat: output RuleTester test case failure index ([#&#8203;19976](https://github.com/eslint/eslint/issues/19976)) (ST-DDT)
- [`7cbcbf9`](7cbcbf9c3c) feat: add `countThis` option to `max-params` ([#&#8203;20236](https://github.com/eslint/eslint/issues/20236)) (Gerkin)
- [`f148a5e`](f148a5eaa1) feat: add error assertion options ([#&#8203;20247](https://github.com/eslint/eslint/issues/20247)) (ST-DDT)
- [`09e6654`](09e66549ec) feat: update error loc of `require-yield` and `no-useless-constructor` ([#&#8203;20267](https://github.com/eslint/eslint/issues/20267)) (Tanuj Kanti)

#### Bug Fixes

- [`436b82f`](436b82f3c0) fix: update eslint ([#&#8203;20473](https://github.com/eslint/eslint/issues/20473)) (renovate\[bot])
- [`1d29d22`](1d29d22fe3) fix: detect default `this` binding in `Array.fromAsync` callbacks ([#&#8203;20456](https://github.com/eslint/eslint/issues/20456)) (Francesco Trotta)
- [`727451e`](727451eff5) fix: fix regression of global mode report range in `strict` rule ([#&#8203;20462](https://github.com/eslint/eslint/issues/20462)) (ntnyq)
- [`e80485f`](e80485fcd2) fix: remove fake `FlatESLint` and `LegacyESLint` exports ([#&#8203;20460](https://github.com/eslint/eslint/issues/20460)) (Francesco Trotta)
- [`9eeff3b`](9eeff3bc13) fix: update esquery ([#&#8203;20423](https://github.com/eslint/eslint/issues/20423)) (cryptnix)
- [`b34b938`](b34b93852d) fix: use `Error.prepareStackTrace` to estimate failing test location ([#&#8203;20436](https://github.com/eslint/eslint/issues/20436)) (Francesco Trotta)
- [`51aab53`](51aab5393b) fix: update eslint ([#&#8203;20443](https://github.com/eslint/eslint/issues/20443)) (renovate\[bot])
- [`23490b2`](23490b2662) fix: handle space before colon in `RuleTester` location estimation ([#&#8203;20433](https://github.com/eslint/eslint/issues/20433)) (Francesco Trotta)
- [`f244dbf`](f244dbf219) fix: use `MessagePlaceholderData` type from `@eslint/core` ([#&#8203;20348](https://github.com/eslint/eslint/issues/20348)) (루밀LuMir)
- [`d186f8c`](d186f8c074) fix: update eslint ([#&#8203;20427](https://github.com/eslint/eslint/issues/20427)) (renovate\[bot])
- [`2332262`](2332262deb) fix: error location should not modify error message in RuleTester ([#&#8203;20421](https://github.com/eslint/eslint/issues/20421)) (Milos Djermanovic)
- [`ab99b21`](ab99b21a67) fix: ensure `filename` is passed as third argument to `verifyAndFix()` ([#&#8203;20405](https://github.com/eslint/eslint/issues/20405)) (루밀LuMir)
- [`8a60f3b`](8a60f3bc80) fix: remove `ecmaVersion` and `sourceType` from `ParserOptions` type ([#&#8203;20415](https://github.com/eslint/eslint/issues/20415)) (Pixel998)
- [`eafd727`](eafd727a06) fix: remove `TDZ` scope type ([#&#8203;20231](https://github.com/eslint/eslint/issues/20231)) (jaymarvelz)
- [`39d1f51`](39d1f51680) fix: correct `Scope` typings ([#&#8203;20404](https://github.com/eslint/eslint/issues/20404)) (sethamus)
- [`2bd0f13`](2bd0f13a92) fix: update `verify` and `verifyAndFix` types ([#&#8203;20384](https://github.com/eslint/eslint/issues/20384)) (Francesco Trotta)
- [`ba6ebfa`](ba6ebfa78d) fix: correct typings for `loadESLint()` and `shouldUseFlatConfig()` ([#&#8203;20393](https://github.com/eslint/eslint/issues/20393)) (루밀LuMir)
- [`e7673ae`](e7673ae096) fix: correct RuleTester typings ([#&#8203;20105](https://github.com/eslint/eslint/issues/20105)) (Pixel998)
- [`53e9522`](53e95222af) fix: strict removed formatters check ([#&#8203;20241](https://github.com/eslint/eslint/issues/20241)) (ntnyq)
- [`b017f09`](b017f094d4) fix: correct `no-restricted-import` messages ([#&#8203;20374](https://github.com/eslint/eslint/issues/20374)) (Francesco Trotta)

#### Documentation

- [`e978dda`](e978ddaab7) docs: Update README (GitHub Actions Bot)
- [`4cecf83`](4cecf8393a) docs: Update README (GitHub Actions Bot)
- [`c79f0ab`](c79f0ab2e2) docs: Update README (GitHub Actions Bot)
- [`773c052`](773c0527c7) docs: Update README (GitHub Actions Bot)
- [`f2962e4`](f2962e46a0) docs: document `meta.docs.frozen` property ([#&#8203;20475](https://github.com/eslint/eslint/issues/20475)) (Pixel998)
- [`8e94f58`](8e94f58beb) docs: fix broken anchor links from gerund heading updates ([#&#8203;20449](https://github.com/eslint/eslint/issues/20449)) (Copilot)
- [`1495654`](14956543d4) docs: Update README (GitHub Actions Bot)
- [`0b8ed5c`](0b8ed5c0aa) docs: document support for `:is` selector alias ([#&#8203;20454](https://github.com/eslint/eslint/issues/20454)) (sethamus)
- [`1c4b33f`](1c4b33fe86) docs: Document policies about ESM-only dependencies ([#&#8203;20448](https://github.com/eslint/eslint/issues/20448)) (Milos Djermanovic)
- [`3e5d38c`](3e5d38cdd5) docs: add missing indentation space in rule example ([#&#8203;20446](https://github.com/eslint/eslint/issues/20446)) (fnx)
- [`63a0c7c`](63a0c7c84b) docs: Update README (GitHub Actions Bot)
- [`65ed0c9`](65ed0c94e7) docs: Update README (GitHub Actions Bot)
- [`b0e4717`](b0e4717d66) docs: \[no-await-in-loop] Expand inapplicability ([#&#8203;20363](https://github.com/eslint/eslint/issues/20363)) (Niklas Hambüchen)
- [`fca421f`](fca421f6a4) docs: Update README (GitHub Actions Bot)
- [`d925c54`](d925c54f04) docs: update config syntax in `no-lone-blocks` ([#&#8203;20413](https://github.com/eslint/eslint/issues/20413)) (Pixel998)
- [`7d5c95f`](7d5c95f281) docs: remove redundant `sourceType: "module"` from rule examples ([#&#8203;20412](https://github.com/eslint/eslint/issues/20412)) (Pixel998)
- [`02e7e71`](02e7e71263) docs: correct `.mts` glob pattern in files with extensions example ([#&#8203;20403](https://github.com/eslint/eslint/issues/20403)) (Ali Essalihi)
- [`264b981`](264b981101) docs: Update README (GitHub Actions Bot)
- [`5a4324f`](5a4324f38e) docs: clarify `"local"` option of `no-unused-vars` ([#&#8203;20385](https://github.com/eslint/eslint/issues/20385)) (Milos Djermanovic)
- [`e593aa0`](e593aa0fd2) docs: improve clarity, grammar, and wording in documentation site README ([#&#8203;20370](https://github.com/eslint/eslint/issues/20370)) (Aditya)
- [`3f5062e`](3f5062ed5f) docs: Add messages property to rule meta documentation ([#&#8203;20361](https://github.com/eslint/eslint/issues/20361)) (Sabya Sachi)
- [`9e5a5c2`](9e5a5c2b6b) docs: remove `Examples` headings from rule docs ([#&#8203;20364](https://github.com/eslint/eslint/issues/20364)) (Milos Djermanovic)
- [`194f488`](194f488a8d) docs: Update README (GitHub Actions Bot)
- [`0f5a94a`](0f5a94a84b) docs: \[class-methods-use-this] explain purpose of rule ([#&#8203;20008](https://github.com/eslint/eslint/issues/20008)) (Kirk Waiblinger)
- [`df5566f`](df5566f826) docs: add Options section to all rule docs ([#&#8203;20296](https://github.com/eslint/eslint/issues/20296)) (sethamus)
- [`adf7a2b`](adf7a2b202) docs: no-unsafe-finally note for generator functions ([#&#8203;20330](https://github.com/eslint/eslint/issues/20330)) (Tom Pereira)
- [`ef7028c`](ef7028c968) docs: Update README (GitHub Actions Bot)
- [`fbae5d1`](fbae5d1885) docs: consistently use "v10.0.0" in migration guide ([#&#8203;20328](https://github.com/eslint/eslint/issues/20328)) (Pixel998)
- [`778aa2d`](778aa2d83e) docs: ignoring default file patterns ([#&#8203;20312](https://github.com/eslint/eslint/issues/20312)) (Tanuj Kanti)
- [`4b5dbcd`](4b5dbcdae5) docs: reorder v10 migration guide ([#&#8203;20315](https://github.com/eslint/eslint/issues/20315)) (Milos Djermanovic)
- [`5d84a73`](5d84a7371d) docs: Update README (GitHub Actions Bot)
- [`37c8863`](37c8863088) docs: fix incorrect anchor link in v10 migration guide ([#&#8203;20299](https://github.com/eslint/eslint/issues/20299)) (Pixel998)
- [`077ff02`](077ff028b6) docs: add migrate-to-10.0.0 doc ([#&#8203;20143](https://github.com/eslint/eslint/issues/20143)) (唯然)
- [`3822e1b`](3822e1b768) docs: Update README (GitHub Actions Bot)

#### Build Related

- [`9f08712`](9f0871236e) Build: changelog update for 10.0.0-rc.2 (Jenkins)
- [`1e2c449`](1e2c449701) Build: changelog update for 10.0.0-rc.1 (Jenkins)
- [`c4c72a8`](c4c72a8d99) Build: changelog update for 10.0.0-rc.0 (Jenkins)
- [`7e4daf9`](7e4daf93d2) Build: changelog update for 10.0.0-beta.0 (Jenkins)
- [`a126a2a`](a126a2ab13) build: add .scss files entry to knip ([#&#8203;20389](https://github.com/eslint/eslint/issues/20389)) (Francesco Trotta)
- [`f5c0193`](f5c01932f6) Build: changelog update for 10.0.0-alpha.1 (Jenkins)
- [`165326f`](165326f046) Build: changelog update for 10.0.0-alpha.0 (Jenkins)

#### Chores

- [`1ece282`](1ece282c22) chore: ignore `/docs/v9.x` in link checker ([#&#8203;20452](https://github.com/eslint/eslint/issues/20452)) (Milos Djermanovic)
- [`034e139`](034e139744) ci: add type integration test for `@html-eslint/eslint-plugin` ([#&#8203;20345](https://github.com/eslint/eslint/issues/20345)) (sethamus)
- [`f3fbc2f`](f3fbc2f60c) chore: set `@eslint/js` version to 10.0.0 to skip releasing it ([#&#8203;20466](https://github.com/eslint/eslint/issues/20466)) (Milos Djermanovic)
- [`afc0681`](afc06817bb) chore: remove scopeManager.addGlobals patch for typescript-eslint parser ([#&#8203;20461](https://github.com/eslint/eslint/issues/20461)) (fnx)
- [`3e5a173`](3e5a173053) refactor: use types from `@eslint/plugin-kit` ([#&#8203;20435](https://github.com/eslint/eslint/issues/20435)) (Pixel998)
- [`11644b1`](11644b1dc2) ci: rename workflows ([#&#8203;20463](https://github.com/eslint/eslint/issues/20463)) (Milos Djermanovic)
- [`2d14173`](2d14173729) chore: fix typos in docs and comments ([#&#8203;20458](https://github.com/eslint/eslint/issues/20458)) (o-m12a)
- [`6742f92`](6742f927ba) test: add endLine/endColumn to invalid test case in no-alert ([#&#8203;20441](https://github.com/eslint/eslint/issues/20441)) (경하)
- [`3e22c82`](3e22c82a87) test: add missing location data to no-template-curly-in-string tests ([#&#8203;20440](https://github.com/eslint/eslint/issues/20440)) (Haeun Kim)
- [`b4b3127`](b4b3127f85) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`f658419`](f6584191cb) refactor: remove `raw` parser option from JS language ([#&#8203;20416](https://github.com/eslint/eslint/issues/20416)) (Pixel998)
- [`2c3efb7`](2c3efb728b) chore: remove `category` from type test fixtures ([#&#8203;20417](https://github.com/eslint/eslint/issues/20417)) (Pixel998)
- [`36193fd`](36193fd9ad) chore: remove `category` from formatter test fixtures ([#&#8203;20418](https://github.com/eslint/eslint/issues/20418)) (Pixel998)
- [`e8d203b`](e8d203b0d9) chore: add JSX language tag validation to `check-rule-examples` ([#&#8203;20414](https://github.com/eslint/eslint/issues/20414)) (Pixel998)
- [`bc465a1`](bc465a1e9d) chore: pin dependencies ([#&#8203;20397](https://github.com/eslint/eslint/issues/20397)) (renovate\[bot])
- [`703f0f5`](703f0f551d) test: replace deprecated rules in `linter` tests ([#&#8203;20406](https://github.com/eslint/eslint/issues/20406)) (루밀LuMir)
- [`ba71baa`](ba71baa872) test: enable `strict` mode in type tests ([#&#8203;20398](https://github.com/eslint/eslint/issues/20398)) (루밀LuMir)
- [`f9c4968`](f9c49683a6) refactor: remove `lib/linter/rules.js` ([#&#8203;20399](https://github.com/eslint/eslint/issues/20399)) (Francesco Trotta)
- [`6f1c48e`](6f1c48e5e7) chore: updates for v9.39.2 release (Jenkins)
- [`54bf0a3`](54bf0a3646) ci: create package manager test ([#&#8203;20392](https://github.com/eslint/eslint/issues/20392)) (루밀LuMir)
- [`3115021`](3115021439) refactor: simplify JSDoc comment detection logic ([#&#8203;20360](https://github.com/eslint/eslint/issues/20360)) (Pixel998)
- [`4345b17`](4345b172a8) chore: update `@eslint-community/regexpp` to `4.12.2` ([#&#8203;20366](https://github.com/eslint/eslint/issues/20366)) (루밀LuMir)
- [`772c9ee`](772c9ee9b6) chore: update dependency [@&#8203;eslint/eslintrc](https://github.com/eslint/eslintrc) to ^3.3.3 ([#&#8203;20359](https://github.com/eslint/eslint/issues/20359)) (renovate\[bot])
- [`0b14059`](0b14059491) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`d6e7bf3`](d6e7bf3064) ci: bump actions/checkout from 5 to 6 ([#&#8203;20350](https://github.com/eslint/eslint/issues/20350)) (dependabot\[bot])
- [`139d456`](139d4567d4) chore: require mandatory headers in rule docs ([#&#8203;20347](https://github.com/eslint/eslint/issues/20347)) (Milos Djermanovic)
- [`3b0289c`](3b0289c7b6) chore: remove unused `.eslintignore` and test fixtures ([#&#8203;20316](https://github.com/eslint/eslint/issues/20316)) (Pixel998)
- [`a463e7b`](a463e7bea0) chore: update dependency js-yaml to v4 \[security] ([#&#8203;20319](https://github.com/eslint/eslint/issues/20319)) (renovate\[bot])
- [`ebfe905`](ebfe90533d) chore: remove redundant rules from eslint-config-eslint ([#&#8203;20327](https://github.com/eslint/eslint/issues/20327)) (Milos Djermanovic)
- [`88dfdb2`](88dfdb23ee) test: add regression tests for message placeholder interpolation ([#&#8203;20318](https://github.com/eslint/eslint/issues/20318)) (fnx)
- [`6ed0f75`](6ed0f758ff) chore: skip type checking in `eslint-config-eslint` ([#&#8203;20323](https://github.com/eslint/eslint/issues/20323)) (Francesco Trotta)
- [`1e2cad5`](1e2cad5f6f) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`9da2679`](9da2679848) chore: update `@eslint/*` dependencies ([#&#8203;20321](https://github.com/eslint/eslint/issues/20321)) (Milos Djermanovic)
- [`0439794`](0439794181) refactor: use types from [@&#8203;eslint/core](https://github.com/eslint/core) ([#&#8203;20235](https://github.com/eslint/eslint/issues/20235)) (jaymarvelz)
- [`cb51ec2`](cb51ec2d6d) test: cleanup `SourceCode#traverse` tests ([#&#8203;20289](https://github.com/eslint/eslint/issues/20289)) (Milos Djermanovic)
- [`897a347`](897a3471d6) chore: remove restriction for `type` in rule tests ([#&#8203;20305](https://github.com/eslint/eslint/issues/20305)) (Pixel998)
- [`d972098`](d972098857) chore: ignore prettier updates in renovate to keep in sync with trunk ([#&#8203;20304](https://github.com/eslint/eslint/issues/20304)) (Pixel998)
- [`a086359`](a086359387) chore: remove redundant `fast-glob` dev-dependency ([#&#8203;20301](https://github.com/eslint/eslint/issues/20301)) (루밀LuMir)
- [`564b302`](564b30215c) chore: install `prettier` as a dev dependency ([#&#8203;20302](https://github.com/eslint/eslint/issues/20302)) (michael faith)
- [`8257b57`](8257b5729d) refactor: correct regex for `eslint-plugin/report-message-format` ([#&#8203;20300](https://github.com/eslint/eslint/issues/20300)) (루밀LuMir)
- [`e251671`](e2516713bc) refactor: extract assertions in RuleTester ([#&#8203;20135](https://github.com/eslint/eslint/issues/20135)) (唯然)
- [`2e7f25e`](2e7f25e189) chore: add `legacy-peer-deps` to `.npmrc` ([#&#8203;20281](https://github.com/eslint/eslint/issues/20281)) (Milos Djermanovic)
- [`39c638a`](39c638a9ae) chore: update eslint-config-eslint dependencies for v10 prereleases ([#&#8203;20278](https://github.com/eslint/eslint/issues/20278)) (Milos Djermanovic)
- [`8533b3f`](8533b3fa28) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to ^0.14.0 ([#&#8203;20288](https://github.com/eslint/eslint/issues/20288)) (renovate\[bot])
- [`796ddf6`](796ddf6db5) chore: update dependency [@&#8203;eslint/js](https://github.com/eslint/js) to ^9.39.1 ([#&#8203;20285](https://github.com/eslint/eslint/issues/20285)) (renovate\[bot])

### [`v9.39.3`](https://github.com/eslint/eslint/releases/tag/v9.39.3)

[Compare Source](https://github.com/eslint/eslint/compare/v9.39.2...v9.39.3)

#### Bug Fixes

- [`791bf8d`](791bf8d7e7) fix: restore TypeScript 4.0 compatibility in types ([#&#8203;20504](https://github.com/eslint/eslint/issues/20504)) (sethamus)

#### Chores

- [`8594a43`](8594a436c2) chore: upgrade [@&#8203;eslint/js](https://github.com/eslint/js)@&#8203;9.39.3 ([#&#8203;20529](https://github.com/eslint/eslint/issues/20529)) (Milos Djermanovic)
- [`9ceef92`](9ceef92fbd) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`af498c6`](af498c63b9) chore: ignore `/docs/v9.x` in link checker ([#&#8203;20453](https://github.com/eslint/eslint/issues/20453)) (Milos Djermanovic)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4zMS4xIiwidXBkYXRlZEluVmVyIjoiNDMuMzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/10
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-22 20:43:48 +00:00
CI
2146c06b02 release v1.0.1 [skip ci] 2026-02-22 03:34:33 +00:00
7d955cd89f chore: Configure Renovate (#7)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

---
### Detected Package Files

 * `Dockerfile` (dockerfile)
 * `helm/kestrelos/values.yaml` (helm-values)
 * `package.json` (npm)
 * `.woodpecker/pr.yml` (woodpecker)
 * `.woodpecker/push.yml` (woodpecker)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Show only the Age and Confidence Merge Confidence badges for pull requests.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.
  - Ensure that every dependency pinned by digest and sourced from GitHub.com contains a link to the commit-to-commit diff
  - Correctly link to the source code for golang.org/x packages
  - Link to pkg.go.dev/... for golang.org/x packages' title
  - Pin Docker digests.
  - Pin `github-action` digests.
  - Enable Renovate configuration migration PRs when needed.
  - Pin dependency versions for development dependencies.
  - Recommended configuration for abandoned packages, treating packages without a release for 1 year as abandoned, while taking into account community-sourced overrides.
  - Wait until the npm package is three days old before raising the update. This a) introduces a short delay to allow for malware researchers and scanners to (possibly) detect any malicious behaviour in packages, and b) prevents the maintainer and/or NPM from unpublishing a package you already upgraded to, breaking builds.
  - Run lock file maintenance (updates) early Monday mornings.
  - Group all `minor` and `patch` updates together.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---

### What to Expect

With your current configuration, Renovate will create 5 Pull Requests:

<details>
<summary>chore(deps): pin dependencies</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/pin-dependencies`
  - Merge into: `main`
  - Pin @iconify-json/tabler to `1.2.26`
  - Pin [@nuxt/eslint](https://github.com/nuxt/eslint) to `1.15.1`
  - Pin [@nuxt/test-utils](https://github.com/nuxt/test-utils) to `4.0.0`
  - Pin [@playwright/test](https://github.com/microsoft/playwright) to `1.58.2`
  - Pin [@vitest/coverage-v8](https://github.com/vitest-dev/vitest) to `4.0.18`
  - Pin [@vue/test-utils](https://github.com/vuejs/test-utils) to `2.4.6`
  - Upgrade alpine to `sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659`
  - Upgrade alpine/helm to `sha256:b5c85b997d83e89d9e8ff9215a14b03864274143981af45eb3fe729cdf782c73`
  - Pin [eslint](https://github.com/eslint/eslint) to `9.39.2`
  - Upgrade [git.keligrubb.com/keligrubb/kestrelos](https://github.com/nodejs/docker-node) to `sha256:a7e93276f5090e2c23792b4eedfb9228bfca182f651989551796356b365205e8`
  - Pin [happy-dom](https://github.com/capricorn86/happy-dom) to `20.6.1`
  - Upgrade mcr.microsoft.com/playwright to `sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d`
  - Upgrade [node](https://github.com/nodejs/node) to `sha256:a81a03dd965b4052269a57fac857004022b522a4bf06e7a739e25e18bce45af2`
  - Pin [vitest](https://github.com/vitest-dev/vitest) to `4.0.18`
  - Upgrade woodpeckerci/plugin-kaniko to `sha256:b88802ba66af95ee28a8ffde08715631ec2892e024b2c74e90e19f73a5c2c602`

</details>

<details>
<summary>chore(deps): update all non-major dependencies</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/all-minor-patch`
  - Merge into: `main`
  - Upgrade [mediasoup-client](https://github.com/versatica/mediasoup-client) to `3.18.7`
  - Upgrade [tar](https://github.com/isaacs/node-tar) to `7.5.9`

</details>

<details>
<summary>chore(deps): update dependency eslint to v10</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/major-eslint-monorepo`
  - Merge into: `main`
  - Upgrade [eslint](https://github.com/eslint/eslint) to `^10.0.0`

</details>

<details>
<summary>fix(deps): update dependency vue-router to v5</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/vue-router-5.x`
  - Merge into: `main`
  - Upgrade [vue-router](https://github.com/vuejs/router) to `^5.0.0`

</details>

<details>
<summary>chore(deps): lock file maintenance</summary>

  - Schedule: ["* 0-3 * * 1"]
  - Branch name: `renovate/lock-file-maintenance`
  - Merge into: `main`
  - Regenerate lock files to use latest dependency versions

</details>

🚸 PR creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See docs for `prHourlyLimit` for details.

---

 Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:94693a990c975907e7f13da3309b9d56ba02b3983519b41786edf5cf031e457c-->

Reviewed-on: #7
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-22 03:34:05 +00:00
68082fd893 patch: fix ci parallelism (#8)
Reviewed-on: #8
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-02-22 03:11:23 +00:00
CI
7fc4685cfc release v1.0.0 [skip ci] 2026-02-17 16:42:30 +00:00
e61e6bc7e3 major: kestrel is now a tak server (#6)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added

- CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity
- Support for TAK stream protocol and traditional XML CoT messages
- TLS/SSL support with automatic fallback to plain TCP
- Username/password authentication for CoT connections
- Real-time device position tracking with TTL-based expiration (90s default)
- API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password`
- TAK Server section in Settings with QR code for iTAK setup
- ATAK password management in Account page for OIDC users
- CoT device markers on map showing real-time positions
- Comprehensive documentation in `docs/` directory
- Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG`
- Dependencies: `fast-xml-parser`, `jszip`, `qrcode`

## Changed

- Authentication system supports CoT password management for OIDC users
- Database schema includes `cot_password_hash` field
- Test suite refactored to follow functional design principles

## Removed

- Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js`

## Security

- XML entity expansion protection in CoT parser
- Enhanced input validation and SQL injection prevention
- Authentication timeout to prevent hanging connections

## Breaking Changes

- Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations.

## Migration Notes

- OIDC users must set ATAK password via Account settings before connecting
- Docker: expose port 8089 (`-p 8089:8089`)
- Kubernetes: update Helm values to expose port 8089

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #6
2026-02-17 16:41:41 +00:00
122 changed files with 5616 additions and 1311 deletions

View File

@@ -2,25 +2,28 @@ when:
- event: pull_request
steps:
- name: lint
- name: install
image: node:24-slim
depends_on: []
commands:
- npm ci
- name: lint
image: node:24-slim
depends_on: [install]
commands:
- npm run lint
- name: test
image: node:24-slim
depends_on: []
depends_on: [install]
commands:
- npm ci
- npm run test
- name: e2e
image: mcr.microsoft.com/playwright:v1.58.2-noble
depends_on: []
depends_on: [install]
commands:
- npm ci
- ./scripts/gen-dev-cert.sh
- npm run test:e2e
environment:

View File

@@ -1,3 +1,15 @@
## [1.0.2] - 2026-02-22
### Changed
- chore(deps): update dependency eslint to v10 (#10)
## [1.0.1] - 2026-02-22
### Changed
- chore: Configure Renovate (#7)
## [1.0.0] - 2026-02-17
### Changed
- kestrel is now a tak server (#6)
## [0.4.0] - 2026-02-15
### Changed
- new nav system (#5)

View File

@@ -16,11 +16,10 @@ USER node
WORKDIR /app
ENV HOST=0.0.0.0
ENV PORT=3000
# Copy app as node user (builder stage ran as root)
COPY --from=builder --chown=node:node /app/.output ./.output
EXPOSE 3000
EXPOSE 3000 8089
CMD ["node", ".output/server/index.mjs"]

View File

@@ -2,6 +2,8 @@
Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream.
![KestrelOS map UI](docs/screenshot.png)
## Stack
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
@@ -34,7 +36,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
npm run dev
```
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's untrusted certificate warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's "untrusted certificate" warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
Without the certs, `npm run dev` still runs over HTTP as before.
@@ -48,31 +50,40 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
- **Mediasoup** server (runs automatically in the Nuxt process)
- **mediasoup-client** (browser library, included automatically)
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 4000049999 on the server.
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000-49999 on the server.
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
## Scripts
- `npm run dev` development server
- `npm run build` production build
- `npm run test` run tests
- `npm run test:coverage` run tests with coverage (85% threshold)
- `npm run lint` ESLint (zero warnings)
- `npm run dev` - development server
- `npm run build` - production build
- `npm run test` - run tests
- `npm run test:coverage` - run tests with coverage (85% threshold)
- `npm run test:e2e` - Playwright E2E tests
- `npm run lint` - ESLint (zero warnings)
## Documentation
Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/installation.md) (npm, Docker, Helm), [authentication](docs/auth.md) (local login, OIDC), [map and cameras](docs/map-and-cameras.md) (adding IPTV, ALPR, CCTV, NVR, etc.), [ATAK and iTAK](docs/atak-itak.md), and [Share live](docs/live-streaming.md) (mobile device as live camera).
## Configuration
- **Devices**: Manage cameras/devices via the API (`/api/devices`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminalcopy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
## Docker
```bash
docker build -t kestrelos:latest .
docker run -p 3000:3000 kestrelos:latest
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
```
## Kubernetes (Helm)
@@ -95,9 +106,9 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
- `major:` breaking changes
- `minor:` new features
- `patch:` bug fixes, docs (default if no prefix)
- `major:` - breaking changes
- `minor:` - new features
- `patch:` - bug fixes, docs (default if no prefix)
Example: `minor: Add map layer toggle`

View File

@@ -66,6 +66,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotEntities: {
type: Array,
default: () => [],
},
canEditPois: {
type: Boolean,
default: false,
@@ -81,6 +85,7 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -89,6 +94,7 @@ const addPoiLatlng = ref(null)
const editPoi = ref(null)
const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' })
const resizeObserver = ref(null)
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd'
@@ -124,7 +130,7 @@ function getPoiIcon(L, poi) {
})
}
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent JS string for Leaflet SVG */
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */
function getLiveSessionIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
return L.divIcon({
@@ -135,6 +141,17 @@ function getLiveSessionIcon(L) {
})
}
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
@@ -201,6 +218,7 @@ function createMap(initialCenter) {
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
}
@@ -291,6 +309,39 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateCotMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
@@ -376,6 +427,8 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
const ctx = mapContext.value
if (ctx) {
@@ -404,8 +457,6 @@ function initMapWithLocation() {
)
}
let resizeObserver = null
onMounted(async () => {
if (!import.meta.client || typeof document === 'undefined') return
const [leaflet, offline] = await Promise.all([
@@ -428,10 +479,10 @@ onMounted(async () => {
nextTick(() => {
if (mapRef.value) {
resizeObserver = new ResizeObserver(() => {
resizeObserver.value = new ResizeObserver(() => {
mapContext.value?.map?.invalidateSize()
})
resizeObserver.observe(mapRef.value)
resizeObserver.value.observe(mapRef.value)
}
})
})
@@ -442,9 +493,9 @@ function onDocumentClick(e) {
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
if (resizeObserver && mapRef.value) {
resizeObserver.disconnect()
resizeObserver = null
if (resizeObserver.value && mapRef.value) {
resizeObserver.value.disconnect()
resizeObserver.value = null
}
destroyMap()
})
@@ -452,4 +503,5 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
</script>

View File

@@ -47,7 +47,7 @@
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP 4000049999 on the server.</li>
<li><strong>Firewall:</strong> Open UDP/TCP 40000-49999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use.</li>
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
</ul>
@@ -66,7 +66,7 @@
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
</p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li>Firewall: open ports 4000049999.</li>
<li>Firewall: open ports 40000-49999.</li>
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
<li>Restrictive NAT: TURN may be required.</li>
</ul>
@@ -104,9 +104,9 @@ const hasStream = ref(false)
const error = ref('')
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let device = null
let recvTransport = null
let consumer = null
const device = ref(null)
const recvTransport = ref(null)
const consumer = ref(null)
async function runFailureReasonCheck() {
failureReason.value = await getWebRTCFailureReason()
@@ -135,16 +135,16 @@ async function setupWebRTC() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
recvTransport = await createRecvTransport(device, props.session.id)
device.value = await createMediasoupDevice(rtpCapabilities)
recvTransport.value = await createRecvTransport(device.value, props.session.id)
recvTransport.on('connectionstatechange', () => {
const state = recvTransport.connectionState
recvTransport.value.on('connectionstatechange', () => {
const state = recvTransport.value.connectionState
if (state === 'connected') connectionState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('LiveSessionPanel: Receive transport connection state changed', {
state,
transportId: recvTransport.id,
transportId: recvTransport.value.id,
sessionId: props.session.id,
})
if (state === 'failed') {
@@ -154,8 +154,8 @@ async function setupWebRTC() {
}
})
const connectionPromise = waitForConnectionState(recvTransport, 10000)
consumer = await consumeProducer(recvTransport, device, props.session.id)
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') {
@@ -163,8 +163,8 @@ async function setupWebRTC() {
runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState,
transportId: recvTransport.id,
consumerId: consumer.id,
transportId: recvTransport.value.id,
consumerId: consumer.value.id,
})
}
else {
@@ -182,14 +182,14 @@ async function setupWebRTC() {
attempts++
}
if (!consumer.track) {
if (!consumer.value.track) {
logError('LiveSessionPanel: No video track available', {
consumerId: consumer.id,
consumerKind: consumer.kind,
consumerPaused: consumer.paused,
consumerClosed: consumer.closed,
consumerProducerId: consumer.producerId,
transportConnectionState: recvTransport?.connectionState,
consumerId: consumer.value.id,
consumerKind: consumer.value.kind,
consumerPaused: consumer.value.paused,
consumerClosed: consumer.value.closed,
consumerProducerId: consumer.value.producerId,
transportConnectionState: recvTransport.value?.connectionState,
})
error.value = 'No video track available - consumer may not be receiving data from producer'
return
@@ -197,14 +197,14 @@ async function setupWebRTC() {
if (!videoRef.value) {
logError('LiveSessionPanel: Video ref not available', {
consumerId: consumer.id,
hasTrack: !!consumer.track,
consumerId: consumer.value.id,
hasTrack: !!consumer.value.track,
})
error.value = 'Video element not available'
return
}
const stream = new MediaStream([consumer.track])
const stream = new MediaStream([consumer.value.track])
videoRef.value.srcObject = stream
hasStream.value = true
@@ -227,7 +227,7 @@ async function setupWebRTC() {
if (resolved) return
resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler)
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
resolve()
}, 5000)
})
@@ -239,7 +239,7 @@ async function setupWebRTC() {
}
catch (playErr) {
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
consumerId: consumer.id,
consumerId: consumer.value.id,
error: playErr.message || String(playErr),
errorName: playErr.name,
videoPaused: videoRef.value.paused,
@@ -248,12 +248,12 @@ async function setupWebRTC() {
// Don't set error - video might still work, just needs user interaction
}
consumer.track.addEventListener('ended', () => {
consumer.value.track.addEventListener('ended', () => {
error.value = 'Video track ended'
hasStream.value = false
})
videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
})
}
catch (err) {
@@ -274,15 +274,15 @@ async function setupWebRTC() {
}
function cleanup() {
if (consumer) {
consumer.close()
consumer = null
if (consumer.value) {
consumer.value.close()
consumer.value = null
}
if (recvTransport) {
recvTransport.close()
recvTransport = null
if (recvTransport.value) {
recvTransport.value.close()
recvTransport.value = null
}
device = null
device.value = null
if (videoRef.value) {
videoRef.value.srcObject = null
}
@@ -308,7 +308,7 @@ watch(
watch(
() => props.session?.hasStream,
(hasStream) => {
if (hasStream && props.session?.id && !device) {
if (hasStream && props.session?.id && !device.value) {
setupWebRTC()
}
else if (!hasStream) {

View File

@@ -1,6 +1,6 @@
/** Fetches devices + live sessions; polls when tab visible. */
const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
export function useCameras(options = {}) {
const { poll: enablePoll = true } = options
@@ -12,6 +12,7 @@ export function useCameras(options = {}) {
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null)
@@ -36,5 +37,5 @@ export function useCameras(options = {}) {
})
onBeforeUnmount(stopPolling)
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
}

View File

@@ -5,17 +5,17 @@
*/
export function useMediaQuery(query) {
const matches = ref(true)
let mql = null
const mql = ref(null)
const handler = (e) => {
matches.value = e.matches
}
onMounted(() => {
mql = window.matchMedia(query)
matches.value = mql.matches
mql.addEventListener('change', handler)
mql.value = window.matchMedia(query)
matches.value = mql.value.matches
mql.value.addEventListener('change', handler)
})
onBeforeUnmount(() => {
if (mql) mql.removeEventListener('change', handler)
if (mql.value) mql.value.removeEventListener('change', handler)
})
return matches
}

View File

@@ -186,18 +186,18 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => {
let tid
const tid = ref(null)
const handler = () => {
const state = transport.connectionState
if (terminal.includes(state)) {
transport.off('connectionstatechange', handler)
if (tid) clearTimeout(tid)
if (tid.value) clearTimeout(tid.value)
resolve(state)
}
}
transport.on('connectionstatechange', handler)
handler()
tid = setTimeout(() => {
tid.value = setTimeout(() => {
transport.off('connectionstatechange', handler)
resolve(transport.connectionState)
}, timeoutMs)

View File

@@ -94,6 +94,71 @@
</div>
</section>
<section
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
ATAK / device password
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-muted">
{{ user.auth_provider === 'oidc' ? 'Set a password to use when connecting from ATAK (check "Use Authentication" and enter your KestrelOS username and this password).' : 'Optionally set a separate password for ATAK; otherwise use your login password.' }}
</p>
<p
v-if="cotPasswordSuccess"
class="mb-3 text-sm text-green-400"
>
ATAK password saved.
</p>
<p
v-if="cotPasswordError"
class="mb-3 text-sm text-red-400"
>
{{ cotPasswordError }}
</p>
<form
class="space-y-3"
@submit.prevent="onSetCotPassword"
>
<div>
<label
for="account-cot-password"
class="kestrel-label"
>ATAK password</label>
<input
id="account-cot-password"
v-model="cotPassword"
type="password"
autocomplete="new-password"
class="kestrel-input"
:placeholder="user.auth_provider === 'oidc' ? 'Set password for ATAK' : 'Optional'"
>
</div>
<div>
<label
for="account-cot-password-confirm"
class="kestrel-label"
>Confirm ATAK password</label>
<input
id="account-cot-password-confirm"
v-model="cotPasswordConfirm"
type="password"
autocomplete="new-password"
class="kestrel-input"
>
</div>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="cotPasswordLoading"
>
{{ cotPasswordLoading ? 'Saving…' : 'Save ATAK password' }}
</button>
</form>
</div>
</section>
<section
v-if="user?.auth_provider === 'local'"
class="mb-8"
@@ -181,6 +246,11 @@ const confirmPassword = ref('')
const passwordLoading = ref(false)
const passwordSuccess = ref(false)
const passwordError = ref('')
const cotPassword = ref('')
const cotPasswordConfirm = ref('')
const cotPasswordLoading = ref(false)
const cotPasswordSuccess = ref(false)
const cotPasswordError = ref('')
const accountInitials = computed(() => {
const id = user.value?.identifier ?? ''
@@ -254,4 +324,34 @@ async function onChangePassword() {
passwordLoading.value = false
}
}
async function onSetCotPassword() {
cotPasswordError.value = ''
cotPasswordSuccess.value = false
if (cotPassword.value !== cotPasswordConfirm.value) {
cotPasswordError.value = 'Password and confirmation do not match.'
return
}
if (cotPassword.value.length < 1) {
cotPasswordError.value = 'Password cannot be empty.'
return
}
cotPasswordLoading.value = true
try {
await $fetch('/api/me/cot-password', {
method: 'PUT',
body: { password: cotPassword.value },
credentials: 'include',
})
cotPassword.value = ''
cotPasswordConfirm.value = ''
cotPasswordSuccess.value = true
}
catch (e) {
cotPasswordError.value = e.data?.message ?? e.message ?? 'Failed to save ATAK password.'
}
finally {
cotPasswordLoading.value = false
}
}
</script>

View File

@@ -6,6 +6,7 @@
:devices="devices ?? []"
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@@ -22,7 +23,7 @@
</template>
<script setup>
const { devices, liveSessions } = useCameras()
const { devices, liveSessions, cotEntities } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const selectedCamera = ref(null)

View File

@@ -112,7 +112,7 @@
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ p.label || '' }}
{{ p.label || '-' }}
</td>
<td class="px-4 py-2 text-kestrel-muted">
{{ p.lat }}

View File

@@ -36,6 +36,67 @@
</div>
</section>
<section class="mb-8">
<h3 class="kestrel-section-label">
TAK Server (ATAK / iTAK)
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-text">
Scan this QR code with iTAK (or ATAK) to add this KestrelOS server. You'll be prompted for your KestrelOS username and password after scanning.
</p>
<div
v-if="takQrDataUrl"
class="inline-block rounded-lg border border-kestrel-border bg-white p-3"
>
<img
:src="takQrDataUrl"
alt="TAK Server QR code"
class="h-48 w-48"
width="192"
height="192"
>
</div>
<p
v-else-if="takQrError"
class="text-sm text-red-400"
>
{{ takQrError }}
</p>
<p
v-else
class="text-sm text-kestrel-muted"
>
Loading QR code…
</p>
<p
v-if="takServerString"
class="mt-3 text-xs text-kestrel-muted break-all"
>
{{ takServerString }}
</p>
<template v-if="cotConfig?.ssl">
<p class="mt-3 text-sm text-kestrel-text">
This server uses a self-signed certificate. iTAK will not connect until it trusts the cert.
</p>
<ol class="mt-2 list-decimal list-inside space-y-1 text-sm text-kestrel-text">
<li>
<strong>Upload server package:</strong> Download below, then in iTAK tap Add Server (+) → Upload server package and select the zip; enter KestrelOS username and password when prompted.
</li>
<li>
<strong>Plain TCP:</strong> Remove or rename <code class="bg-kestrel-surface px-1 rounded">.dev-certs</code>, restart, then in iTAK add the server with SSL disabled.
</li>
</ol>
<a
href="/api/cot/server-package"
download="kestrelos-itak-server-package.zip"
class="kestrel-btn-secondary mt-3 inline-block"
>
Download server package (zip)
</a>
</template>
</div>
</section>
<section>
<h3 class="kestrel-section-label">
About
@@ -67,6 +128,11 @@ const tilesMessage = ref('')
const tilesMessageSuccess = ref(false)
const tilesLoading = ref(false)
const cotConfig = ref(null)
const takQrDataUrl = ref('')
const takQrError = ref('')
const takServerString = ref('')
async function loadTilesStored() {
if (typeof window === 'undefined') return
try {
@@ -106,7 +172,26 @@ async function onClearTiles() {
}
}
async function loadTakQr() {
if (typeof window === 'undefined') return
try {
const res = await $fetch('/api/cot/config')
cotConfig.value = res
const hostname = window.location.hostname
const port = res?.port ?? 8089
const protocol = res?.ssl ? 'ssl' : 'tcp'
const str = `KestrelOS,${hostname},${port},${protocol}`
takServerString.value = str
const QRCode = (await import('qrcode')).default
takQrDataUrl.value = await QRCode.toDataURL(str, { width: 192, margin: 1 })
}
catch (e) {
takQrError.value = e?.data?.error ?? e?.message ?? 'Could not load TAK server config.'
}
}
onMounted(() => {
loadTilesStored()
loadTakQr()
})
</script>

View File

@@ -39,7 +39,7 @@
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 on the server.</li>
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000-49999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul>
@@ -68,7 +68,7 @@
v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
>
● Live you appear on the map
● Live - you appear on the map
</div>
</div>
@@ -122,11 +122,11 @@ const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let locationWatchId = null
let locationIntervalId = null
let device = null
let sendTransport = null
let producer = null
const locationWatchId = ref(null)
const locationIntervalId = ref(null)
const device = ref(null)
const sendTransport = ref(null)
const producer = ref(null)
async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason()
@@ -194,8 +194,8 @@ async function startSharing() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
sendTransport = await createSendTransport(device, sessionId.value, {
device.value = await createMediasoupDevice(rtpCapabilities)
sendTransport.value = await createSendTransport(device.value, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => {
webrtcState.value = 'failed'
@@ -208,31 +208,31 @@ async function startSharing() {
if (!videoTrack) {
throw new Error('No video track available')
}
producer = await sendTransport.produce({ track: videoTrack })
producer.value = await sendTransport.value.produce({ track: videoTrack })
// Monitor producer events
producer.on('transportclose', () => {
producer.value.on('transportclose', () => {
logWarn('share-live: Producer transport closed', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
producerId: producer.value.id,
producerPaused: producer.value.paused,
producerClosed: producer.value.closed,
})
})
producer.on('trackended', () => {
producer.value.on('trackended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
producerId: producer.value.id,
producerPaused: producer.value.paused,
producerClosed: producer.value.closed,
})
})
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.on('connectionstatechange', () => {
const state = sendTransport.connectionState
sendTransport.value.on('connectionstatechange', () => {
const state = sendTransport.value.connectionState
if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', {
state,
transportId: sendTransport.id,
producerId: producer.id,
transportId: sendTransport.value.id,
producerId: producer.value.id,
})
if (state === 'failed') {
webrtcState.value = 'failed'
@@ -241,25 +241,25 @@ async function startSharing() {
}
})
// Monitor track state
if (producer.track) {
producer.track.addEventListener('ended', () => {
if (producer.value.track) {
producer.value.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
trackId: producer.track.id,
trackReadyState: producer.track.readyState,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
producerId: producer.value.id,
trackId: producer.value.track.id,
trackReadyState: producer.value.track.readyState,
trackEnabled: producer.value.track.enabled,
trackMuted: producer.value.track.muted,
})
})
producer.track.addEventListener('mute', () => {
producer.value.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', {
producerId: producer.id,
trackId: producer.track.id,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
producerId: producer.value.id,
trackId: producer.value.track.id,
trackEnabled: producer.value.track.enabled,
trackMuted: producer.value.track.muted,
})
})
producer.track.addEventListener('unmute', () => {})
producer.value.track.addEventListener('unmute', () => {})
}
webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…')
@@ -273,7 +273,7 @@ async function startSharing() {
return
}
// 5. Get location (continuous) also requires HTTPS on mobile Safari
// 5. Get location (continuous) - also requires HTTPS on mobile Safari
if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.')
cleanup()
@@ -281,7 +281,7 @@ async function startSharing() {
}
try {
await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition(
locationWatchId.value = navigator.geolocation.watchPosition(
(pos) => {
resolve(pos)
},
@@ -332,9 +332,9 @@ async function startSharing() {
}
catch (e) {
if (e?.statusCode === 404) {
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationIntervalId = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
locationIntervalId.value = null
}
sharing.value = false
if (!locationUpdate404Logged) {
@@ -350,7 +350,7 @@ async function startSharing() {
}
await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000)
locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
}
catch (e) {
starting.value = false
@@ -363,23 +363,23 @@ async function startSharing() {
}
function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId)
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId.value)
}
locationWatchId = null
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationWatchId.value = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
}
locationIntervalId = null
if (producer) {
producer.close()
producer = null
locationIntervalId.value = null
if (producer.value) {
producer.value.close()
producer.value = null
}
if (sendTransport) {
sendTransport.close()
sendTransport = null
if (sendTransport.value) {
sendTransport.value.close()
sendTransport.value = null
}
device = null
device.value = null
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null

View File

@@ -1,19 +1,19 @@
/** Client-side logger: sends to server, falls back to console. */
let sessionId = null
let userId = null
const sessionId = ref(null)
const userId = ref(null)
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
export function initLogger(sessId, uid) {
sessionId = sessId
userId = uid
sessionId.value = sessId
userId.value = uid
}
function sendToServer(level, message, data) {
setTimeout(() => {
$fetch('/api/log', {
method: 'POST',
body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
credentials: 'include',
}).catch(() => { /* server down - don't spam console */ })
}, 0)

11
docs/README.md Normal file
View File

@@ -0,0 +1,11 @@
# KestrelOS Documentation
Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
## Quick Start
1. [Installation](installation.md) - npm, Docker, or Helm
2. [Authentication](auth.md) - First login (bootstrap admin or OIDC)
3. [Map and cameras](map-and-cameras.md) - Add devices and view streams
4. [ATAK and iTAK](atak-itak.md) - Connect TAK clients (port 8089)
5. [Share live](live-streaming.md) - Stream from mobile device (HTTPS required)

79
docs/atak-itak.md Normal file
View File

@@ -0,0 +1,79 @@
# ATAK and iTAK
KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
## Connection
**Host:** KestrelOS hostname/IP
**Port:** `8089` (CoT)
**SSL:** Enable if server uses TLS (`.dev-certs/` or production cert)
**Authentication:**
- **Username:** KestrelOS identifier
- **Password:** Login password (local) or ATAK password (OIDC; set in **Account**)
## ATAK (Android)
1. **Settings****Network****Connections** → Add **TAK Server**
2. Set **Host** and **Port** (`8089`)
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## iTAK (iOS)
**Option A - QR code (easiest):**
1. KestrelOS **Settings****TAK Server** → Scan QR with iTAK
2. Enter username/password when prompted
**Option B - Manual:**
1. **Settings****Network** → Add **TAK Server**
2. Set **Host**, **Port** (`8089`), enable SSL if needed
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## Self-Signed Certificate (iTAK)
If server uses self-signed cert (`.dev-certs/`):
**Upload server package:**
1. KestrelOS **Settings****TAK Server****Download server package (zip)**
2. Transfer to iPhone (AirDrop, email, Safari)
3. iTAK: **Settings****Network****Servers****+** → **Upload server package**
4. Enter username/password
**Or use plain TCP:**
1. Stop KestrelOS, remove `.dev-certs/`, restart
2. Add server with **SSL disabled**
**ATAK (Android):** Download trust store from `https://your-server/api/cot/truststore`, import `.p12` (password: `kestrelos`), or use server package/plain TCP.
## OIDC Users
OIDC users must set an **ATAK password** first:
1. Sign in with OIDC
2. **Account****ATAK / device password** → set password
3. Use KestrelOS username + ATAK password in TAK client
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `COT_PORT` | `8089` | CoT server port |
| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
| `COT_REQUIRE_AUTH` | `true` | Require authentication |
| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
## Troubleshooting
**"Error authenticating" with no `[cot]` logs:**
- Connection not reaching server (TLS handshake failed or firewall blocking)
- Check server logs show `[cot] CoT server listening on 0.0.0.0:8089`
- Verify port `8089` (not `3000`) and firewall allows it
- For TLS: trust cert (server package) or use plain TCP
**"Error authenticating" with `[cot]` logs:**
- Username must be KestrelOS identifier
- Password must match (local: login password; OIDC: ATAK password)
**Devices not on map:** They appear only while sending updates; drop off after TTL (~90s).

39
docs/auth.md Normal file
View File

@@ -0,0 +1,39 @@
# Authentication
KestrelOS supports **local login** (username/email + password) and optional **OIDC** (SSO). All users must sign in.
## Local Login
**First run:** On first start, KestrelOS creates an admin account:
- If `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` are set → that account is created
- Otherwise → default admin (`admin`) with random password printed in terminal
**Sign in:** Open `/login`, enter identifier and password. Change password or add users via **Members** (admin only).
## OIDC (SSO)
**Enable:** Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Optional: `OIDC_LABEL`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`.
**IdP setup:**
1. Create OIDC client in your IdP (Keycloak, Auth0, etc.)
2. Set redirect URI: `https://<your-host>/api/auth/oidc/callback`
3. Copy Client ID and Secret to env vars
**Sign up:** Users sign up at the IdP. First OIDC login in KestrelOS creates their account automatically.
**Redirect URI:** Defaults to `{APP_URL}/api/auth/oidc/callback` (uses `NUXT_APP_URL`/`APP_URL` or falls back to `HOST`/`PORT`).
## OIDC Users and ATAK/iTAK
OIDC users don't have a KestrelOS password. To use ATAK/iTAK:
1. Sign in with OIDC
2. Go to **Account** → set **ATAK password**
3. Use KestrelOS username + ATAK password in TAK client
## Roles
- **Admin** - Manage users, edit POIs, add/edit devices (API)
- **Leader** - Edit POIs, add/edit devices (API)
- **Member** - View map/cameras/POIs, use Share live
Only admins can change roles (Members page).

61
docs/installation.md Normal file
View File

@@ -0,0 +1,61 @@
# Installation
Run KestrelOS from source (npm), Docker, or Kubernetes (Helm).
## npm (from source)
```bash
git clone <repository-url> kestrelos
cd kestrelos
npm install
npm run dev
```
Open **http://localhost:3000**. First run creates `data/kestrelos.db` and bootstraps an admin (see [Authentication](auth.md)).
**Production:**
```bash
npm run build
npm run preview
# or
node .output/server/index.mjs
```
Set `HOST=0.0.0.0` and `PORT` for production.
## Docker
```bash
docker build -t kestrelos:latest .
docker run -p 3000:3000 -p 8089:8089 \
-v kestrelos-data:/app/data \
kestrelos:latest
```
Expose ports **3000** (web/API) and **8089** (CoT for ATAK/iTAK).
## Helm (Kubernetes)
**From registry:**
```bash
helm repo add keligrubb --username USER --password TOKEN \
https://git.keligrubb.com/api/packages/keligrubb/helm
helm install kestrelos keligrubb/kestrelos
```
**From source:**
```bash
helm install kestrelos ./helm/kestrelos
```
Configure in `helm/kestrelos/values.yaml`. Health: `GET /health`, `/health/live`, `/health/ready`.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `HOST` | Nuxt default | Bind address (use `0.0.0.0` for all interfaces) |
| `PORT` | `3000` | Web/API port |
| `DB_PATH` | `data/kestrelos.db` | SQLite database path |
See [Authentication](auth.md) for auth variables. See [ATAK and iTAK](atak-itak.md) for CoT options.

44
docs/live-streaming.md Normal file
View File

@@ -0,0 +1,44 @@
# Share Live
Stream your phone's camera and location to KestrelOS. Appears as a **live session** on the map and in **Cameras**. Uses **WebRTC** (Mediasoup) and requires **HTTPS** on mobile.
## Usage
1. Open **Share live** (sidebar → **Share live** or `/share-live`)
2. Tap **Start sharing**, allow camera/location permissions
3. Device appears on map and in **Cameras**
4. Tap **Stop sharing** to end
**Permissions:** Admin/leader can start sharing. All users can view live sessions.
## Requirements
- **HTTPS** (browsers require secure context for camera/geolocation)
- **Camera and location permissions**
- **WebRTC ports:** UDP/TCP `40000-49999` open on server
## Local Development
**Generate self-signed cert:**
```bash
chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123 # Your LAN IP
npm run dev
```
**On phone:** Open `https://192.168.1.123:3000`, accept cert warning, sign in, use Share live.
## WebRTC Configuration
- Server auto-detects LAN IP for WebRTC
- **Docker/multiple NICs:** Set `MEDIASOUP_ANNOUNCED_IP` to client-reachable IP/hostname
- **"Wrong host" error:** Use same URL on phone/server, or set `MEDIASOUP_ANNOUNCED_IP`
## Troubleshooting
| Issue | Fix |
|-------|-----|
| "HTTPS required" | Use `https://` (not `http://`) |
| "Media devices not available" | Ensure HTTPS and browser permissions |
| "WebRTC: failed" / "Wrong host" | Set `MEDIASOUP_ANNOUNCED_IP`, open firewall ports `40000-49999` |
| Stream not visible | Check server reachability and firewall |

52
docs/map-and-cameras.md Normal file
View File

@@ -0,0 +1,52 @@
# Map and Cameras
KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and ATAK/iTAK positions. Click markers or use **Cameras** page to view streams.
## Map Layers
- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
- **POIs** - Points of interest (admin/leader can edit)
- **Live sessions** - Mobile devices streaming via Share live
- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
## Cameras
A **camera** is either:
1. A **device** - Fixed feed with stream URL
2. A **live session** - Mobile device streaming via Share live
View via map markers or **Cameras** page (sidebar).
## Device Types
| device_type | Use case |
|-------------|----------|
| `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone` | Labeling/filtering |
**source_type:** `mjpeg` (MJPEG over HTTP) or `hls` (HLS `.m3u8` playlist)
Stream URLs must be `http://` or `https://`.
## API: Devices
**Create:** `POST /api/devices` (admin/leader)
```json
{
"name": "Main gate ALPR",
"device_type": "alpr",
"lat": 37.7749,
"lng": -122.4194,
"stream_url": "https://alpr.example.com/stream.m3u8",
"source_type": "hls"
}
```
**List:** `GET /api/devices`
**Update:** `PATCH /api/devices/:id`
**Delete:** `DELETE /api/devices/:id`
**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
## POIs
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -2,5 +2,5 @@ apiVersion: v2
name: kestrelos
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
type: application
version: 0.4.0
appVersion: "0.4.0"
version: 1.0.2
appVersion: "1.0.2"

View File

@@ -2,7 +2,7 @@ replicaCount: 1
image:
repository: git.keligrubb.com/keligrubb/kestrelos
tag: 0.4.0
tag: 1.0.2
pullPolicy: IfNotPresent
service:

View File

@@ -32,10 +32,12 @@ export default defineNuxtConfig({
public: {
version: pkg.version ?? '',
},
cotTtlMs: 90_000,
cotRequireAuth: true,
cotDebug: false,
},
devServer: {
host: '0.0.0.0',
port: 3000,
...(useDevHttps
? { https: { key: devKey, cert: devCert } }
: {}),

815
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kestrelos",
"version": "0.4.0",
"version": "1.0.2",
"private": true,
"type": "module",
"scripts": {
@@ -10,6 +10,7 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test test/e2e",
"test:e2e:ui": "playwright test --ui test/e2e",
@@ -20,16 +21,19 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
"qrcode": "^1.5.4",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",
"vue-router": "^5.0.0",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -39,7 +43,7 @@
"@playwright/test": "^1.58.2",
"@vitest/coverage-v8": "^4.0.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^9.0.0",
"eslint": "^10.0.0",
"happy-dom": "^20.6.1",
"vitest": "^4.0.0"
},

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -1,3 +1,3 @@
import { getAuthConfig } from '../../utils/authConfig.js'
import { getAuthConfig } from '../../utils/oidc.js'
export default defineEventHandler(() => getAuthConfig())

View File

@@ -1,7 +1,7 @@
import { setCookie } from 'h3'
import { getDb } from '../../utils/db.js'
import { verifyPassword } from '../../utils/password.js'
import { getSessionMaxAgeDays } from '../../utils/session.js'
import { getSessionMaxAgeDays } from '../../utils/constants.js'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
@@ -15,6 +15,10 @@ export default defineEventHandler(async (event) => {
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
throw createError({ statusCode: 401, message: 'Invalid credentials' })
}
// Invalidate all existing sessions for this user to prevent session fixation
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()

View File

@@ -1,5 +1,5 @@
import { getAuthConfig } from '../../../utils/authConfig.js'
import {
getAuthConfig,
getOidcConfig,
getOidcRedirectUri,
createOidcParams,

View File

@@ -6,7 +6,7 @@ import {
exchangeCode,
} from '../../../utils/oidc.js'
import { getDb } from '../../../utils/db.js'
import { getSessionMaxAgeDays } from '../../../utils/session.js'
import { getSessionMaxAgeDays } from '../../../utils/constants.js'
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
@@ -74,6 +74,9 @@ export default defineEventHandler(async (event) => {
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
}
// Invalidate all existing sessions for this user to prevent session fixation
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()

View File

@@ -1,12 +1,19 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getActiveSessions } from '../utils/liveSessions.js'
import { getActiveEntities } from '../utils/cotStore.js'
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
const config = useRuntimeConfig()
const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
const [db, sessions, cotEntities] = await Promise.all([
getDb(),
getActiveSessions(),
getActiveEntities(ttlMs),
])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions }
return { devices, liveSessions: sessions, cotEntities }
})

View File

@@ -0,0 +1,8 @@
import { getCotSslPaths, getCotPort } from '../../utils/cotSsl.js'
/** Public CoT server config for QR code / client setup (port and whether TLS is used). */
export default defineEventHandler(() => {
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
return { port: getCotPort(), ssl: Boolean(paths) }
})

View File

@@ -0,0 +1,60 @@
import { existsSync } from 'node:fs'
import JSZip from 'jszip'
import { getCotSslPaths, getCotPort, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
/**
* Build config.pref XML for iTAK: server connection + CA cert for trust (credentials entered in app).
* connectString format: host:port:ssl or host:port:tcp
*/
function buildConfigPref(hostname, port, ssl) {
const connectString = `${hostname}:${port}:${ssl ? 'ssl' : 'tcp'}`
return `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<preference-set id="com.atakmap.app_preferences">
<entry key="connectionEntry">1</entry>
<entry key="description">KestrelOS</entry>
<entry key="enabled">true</entry>
<entry key="connectString">${escapeXml(connectString)}</entry>
<entry key="caCertPath">cert/caCert.p12</entry>
<entry key="caCertPassword">${escapeXml(TRUSTSTORE_PASSWORD)}</entry>
<entry key="cacheCredentials">true</entry>
</preference-set>
`
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export default defineEventHandler(async (event) => {
requireAuth(event)
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
if (!paths || !existsSync(paths.certPath)) {
setResponseStatus(event, 404)
return { error: `CoT server is not using TLS. Server package ${COT_TLS_REQUIRED_MESSAGE} Use the QR code and add the server with SSL disabled (plain TCP) instead.` }
}
const hostname = getRequestURL(event).hostname
const port = getCotPort()
try {
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
const zip = new JSZip()
zip.file('config.pref', buildConfigPref(hostname, port, true))
zip.folder('cert').file('caCert.p12', p12)
const blob = await zip.generateAsync({ type: 'nodebuffer' })
setHeader(event, 'Content-Type', 'application/zip')
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-itak-server-package.zip"')
return blob
}
catch (err) {
setResponseStatus(event, 500)
return { error: 'Failed to build server package.', detail: err?.message }
}
})

View File

@@ -0,0 +1,24 @@
import { existsSync } from 'node:fs'
import { getCotSslPaths, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler((event) => {
requireAuth(event)
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
if (!paths || !existsSync(paths.certPath)) {
setResponseStatus(event, 404)
return { error: `CoT server is not using TLS or cert not found. Trust store ${COT_TLS_REQUIRED_MESSAGE}` }
}
try {
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
setHeader(event, 'Content-Type', 'application/x-pkcs12')
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-cot-truststore.p12"')
return p12
}
catch (err) {
setResponseStatus(event, 500)
return { error: 'Failed to build trust store.', detail: err?.message }
}
})

View File

@@ -1,4 +1,4 @@
import { getDb } from '../utils/db.js'
import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
@@ -7,13 +7,15 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
const id = crypto.randomUUID()
const { run, get } = await getDb()
await run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
)
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
const device = rowToDevice(row)
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
return sanitizeDeviceForResponse(device)
const db = await getDb()
return withTransaction(db, async ({ run, get }) => {
await run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
)
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
const device = rowToDevice(row)
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
return sanitizeDeviceForResponse(device)
})
})

View File

@@ -1,55 +1,49 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event).catch(() => ({}))) || {}
const updates = []
const params = []
const updates = {}
if (typeof body.name === 'string') {
updates.push('name = ?')
params.push(body.name.trim())
updates.name = body.name.trim()
}
if (DEVICE_TYPES.includes(body.device_type)) {
updates.push('device_type = ?')
params.push(body.device_type)
updates.device_type = body.device_type
}
if (body.vendor !== undefined) {
updates.push('vendor = ?')
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null
}
if (Number.isFinite(body.lat)) {
updates.push('lat = ?')
params.push(body.lat)
updates.lat = body.lat
}
if (Number.isFinite(body.lng)) {
updates.push('lng = ?')
params.push(body.lng)
updates.lng = body.lng
}
if (typeof body.stream_url === 'string') {
updates.push('stream_url = ?')
params.push(body.stream_url.trim())
updates.stream_url = body.stream_url.trim()
}
if (SOURCE_TYPES.includes(body.source_type)) {
updates.push('source_type = ?')
params.push(body.source_type)
updates.source_type = body.source_type
}
if (body.config !== undefined) {
updates.push('config = ?')
params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null)
}
const { run, get } = await getDb()
if (updates.length === 0) {
if (Object.keys(updates).length === 0) {
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row)
return device ? sanitizeDeviceForResponse(device) : row
}
params.push(id)
await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
const { query, params } = buildUpdateQuery('devices', null, updates)
if (query) {
await run(query, [...params, id])
}
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row)

View File

@@ -1,35 +1,38 @@
import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
return await acquire(`session-delete-${id}`, async () => {
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
// Clean up producer if it exists
if (session.producerId) {
const producer = getProducer(session.producerId)
if (producer) {
producer.close()
// Clean up producer if it exists
if (session.producerId) {
const producer = getProducer(session.producerId)
if (producer) {
producer.close()
}
}
}
// Clean up transport if it exists
if (session.transportId) {
const transport = getTransport(session.transportId)
if (transport) {
transport.close()
// Clean up transport if it exists
if (session.transportId) {
const transport = getTransport(session.transportId)
if (transport) {
transport.close()
}
}
}
// Clean up router
await closeRouter(id)
// Clean up router
await closeRouter(id)
deleteLiveSession(id)
return { ok: true }
await deleteLiveSession(id)
return { ok: true }
})
})

View File

@@ -1,31 +1,57 @@
import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
const body = await readBody(event).catch(() => ({}))
const lat = Number(body?.lat)
const lng = Number(body?.lng)
const updates = {}
if (Number.isFinite(lat)) updates.lat = lat
if (Number.isFinite(lng)) updates.lng = lng
if (Object.keys(updates).length) {
updateLiveSession(id, updates)
if (Object.keys(updates).length === 0) {
// No updates, just return current session
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
return {
id: session.id,
label: session.label,
lat: session.lat,
lng: session.lng,
updatedAt: session.updatedAt,
}
}
const updated = getLiveSession(id)
return {
id: updated.id,
label: updated.label,
lat: updated.lat,
lng: updated.lng,
updatedAt: updated.updatedAt,
}
// Use lock to atomically check and update session
return await acquire(`session-patch-${id}`, async () => {
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
try {
const updated = await updateLiveSession(id, updates)
// Re-verify after update (updateLiveSession throws if session not found)
if (!updated || updated.userId !== user.id) {
throw createError({ statusCode: 404, message: 'Live session not found' })
}
return {
id: updated.id,
label: updated.label,
lat: updated.lat,
lng: updated.lng,
updatedAt: updated.updatedAt,
}
}
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Live session not found' })
}
throw err
}
})
})

View File

@@ -1,40 +1,44 @@
import { requireAuth } from '../../utils/authHelpers.js'
import {
createSession,
getOrCreateSession,
getActiveSessionByUserId,
deleteLiveSession,
} from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event).catch(() => ({}))
const label = typeof body?.label === 'string' ? body.label.trim() : ''
const label = typeof body?.label === 'string' ? body.label.trim().slice(0, 100) : ''
// Replace any existing live session for this user (one session per user)
const existing = getActiveSessionByUserId(user.id)
if (existing) {
if (existing.producerId) {
const producer = getProducer(existing.producerId)
if (producer) producer.close()
// Atomically get or create session, replacing existing if needed
return await acquire(`session-start-${user.id}`, async () => {
const existing = await getActiveSessionByUserId(user.id)
if (existing) {
// Clean up existing session resources
if (existing.producerId) {
const producer = getProducer(existing.producerId)
if (producer) producer.close()
}
if (existing.transportId) {
const transport = getTransport(existing.transportId)
if (transport) transport.close()
}
if (existing.routerId) {
await closeRouter(existing.id).catch((err) => {
console.error('[live.start] Error closing previous router:', err)
})
}
await deleteLiveSession(existing.id)
console.log('[live.start] Replaced previous session:', existing.id)
}
if (existing.transportId) {
const transport = getTransport(existing.transportId)
if (transport) transport.close()
}
if (existing.routerId) {
await closeRouter(existing.id).catch((err) => {
console.error('[live.start] Error closing previous router:', err)
})
}
deleteLiveSession(existing.id)
console.log('[live.start] Replaced previous session:', existing.id)
}
const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
return {
id: session.id,
label: session.label,
}
const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`)
console.log('[live.start] Session ready:', { id: session.id, userId: user.id, label: session.label })
return {
id: session.id,
label: session.label,
}
})
})

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getTransport } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
requireAuth(event) // Verify authentication
const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, dtlsParameters } = body
@@ -15,8 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
// Note: Both publisher and viewers can connect their own transports
// The transportId ensures they can only connect transports they created
// Verify user has permission to connect transport for this session
// Only session owner or admin/leader can connect transports
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const transport = getTransport(transportId)
if (!transport) {

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
requireAuth(event) // Verify authentication
const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, rtpCapabilities } = body
@@ -15,6 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) {
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
}
// Authorization check: only session owner or admin/leader can consume
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (!session.producerId) {
throw createError({ statusCode: 404, message: 'No producer available for this session' })
}

View File

@@ -1,6 +1,7 @@
import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getTransport, producers } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
@@ -11,33 +12,48 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
}
const session = getLiveSession(sessionId)
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
if (session.userId !== user.id) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return await acquire(`create-producer-${sessionId}`, async () => {
const session = getLiveSession(sessionId)
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
if (session.userId !== user.id) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const transport = getTransport(transportId)
if (!transport) {
throw createError({ statusCode: 404, message: 'Transport not found' })
}
const transport = getTransport(transportId)
if (!transport) {
throw createError({ statusCode: 404, message: 'Transport not found' })
}
const producer = await transport.produce({ kind, rtpParameters })
producers.set(producer.id, producer)
producer.on('close', () => {
producers.delete(producer.id)
const s = getLiveSession(sessionId)
if (s && s.producerId === producer.id) {
updateLiveSession(sessionId, { producerId: null })
const producer = await transport.produce({ kind, rtpParameters })
producers.set(producer.id, producer)
producer.on('close', async () => {
producers.delete(producer.id)
const s = getLiveSession(sessionId)
if (s && s.producerId === producer.id) {
try {
await updateLiveSession(sessionId, { producerId: null })
}
catch {
// Ignore errors during cleanup
}
}
})
try {
await updateLiveSession(sessionId, { producerId: producer.id })
}
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Session not found' })
}
throw err
}
return {
id: producer.id,
kind: producer.kind,
}
})
updateLiveSession(sessionId, { producerId: producer.id })
return {
id: producer.id,
kind: producer.kind,
}
})

View File

@@ -2,6 +2,7 @@ import { getRequestURL } from 'h3'
import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, createTransport } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
@@ -12,28 +13,38 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId required' })
}
const session = getLiveSession(sessionId)
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
return await acquire(`create-transport-${sessionId}`, async () => {
const session = getLiveSession(sessionId)
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
// Only publisher (session owner) can create producer transport
// Viewers can create consumer transports
if (isProducer && session.userId !== user.id) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
// Only publisher (session owner) can create producer transport
// Viewers can create consumer transports
if (isProducer && session.userId !== user.id) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const url = getRequestURL(event)
const requestHost = url.hostname
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, requestHost)
const url = getRequestURL(event)
const requestHost = url.hostname
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, requestHost)
if (isProducer) {
updateLiveSession(sessionId, {
transportId: transport.id,
routerId: router.id,
})
}
if (isProducer) {
try {
await updateLiveSession(sessionId, {
transportId: transport.id,
routerId: router.id,
})
}
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Session not found' })
}
throw err
}
}
return params
return params
})
})

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
const user = requireAuth(event)
const sessionId = getQuery(event).sessionId
if (!sessionId) {
@@ -15,6 +15,11 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Session not found' })
}
// Only session owner or admin/leader can access
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const router = await getRouter(sessionId)
return router.rtpCapabilities
})

View File

@@ -6,7 +6,14 @@ import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) return { ok: true }
const path = join(getAvatarsDir(), user.avatar_path)
// Validate avatar path to prevent path traversal attacks
const filename = user.avatar_path
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
}
const path = join(getAvatarsDir(), filename)
await unlink(path).catch(() => {})
const { run } = await getDb()
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])

View File

@@ -8,8 +8,15 @@ const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
const path = join(getAvatarsDir(), user.avatar_path)
const ext = user.avatar_path.split('.').pop()?.toLowerCase()
// Validate avatar path to prevent path traversal attacks
const filename = user.avatar_path
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
}
const path = join(getAvatarsDir(), filename)
const ext = filename.split('.').pop()?.toLowerCase()
const mime = MIME[ext] ?? 'application/octet-stream'
try {
const buf = await readFile(path)

View File

@@ -8,6 +8,24 @@ const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
/**
* Validate image content using magic bytes to prevent MIME type spoofing.
* @param {Buffer} buffer - File data buffer
* @returns {string|null} Detected MIME type or null if invalid
*/
function validateImageContent(buffer) {
if (!buffer || buffer.length < 8) return null
// JPEG: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return 'image/jpeg'
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return 'image/png'
}
return null
}
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const form = await readMultipartFormData(event)
@@ -16,7 +34,14 @@ export default defineEventHandler(async (event) => {
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
const mime = file.type ?? ''
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
const ext = EXT_BY_MIME[mime] ?? 'jpg'
// Validate file content matches declared MIME type
const actualMime = validateImageContent(file.data)
if (!actualMime || actualMime !== mime) {
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
}
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
const filename = `${user.id}.${ext}`
const dir = getAvatarsDir()
const path = join(dir, filename)

View File

@@ -0,0 +1,26 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const body = await readBody(event).catch(() => ({}))
const password = body?.password
if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'Password is required' })
}
const { get, run } = await getDb()
const user = await get(
'SELECT id, auth_provider FROM users WHERE id = ?',
[currentUser.id],
)
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
const hash = hashPassword(password)
await run('UPDATE users SET cot_password_hash = ? WHERE id = ?', [hash, currentUser.id])
return { ok: true }
})

View File

@@ -1,6 +1,6 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
import { POI_ICON_TYPES } from '../utils/validation.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })

View File

@@ -1,39 +1,37 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
import { POI_ICON_TYPES } from '../../utils/validation.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event)) || {}
const updates = []
const params = []
const updates = {}
if (typeof body.label === 'string') {
updates.push('label = ?')
params.push(body.label.trim())
updates.label = body.label.trim()
}
if (POI_ICON_TYPES.includes(body.iconType)) {
updates.push('icon_type = ?')
params.push(body.iconType)
updates.icon_type = body.iconType
}
if (Number.isFinite(body.lat)) {
updates.push('lat = ?')
params.push(body.lat)
updates.lat = body.lat
}
if (Number.isFinite(body.lng)) {
updates.push('lng = ?')
params.push(body.lng)
updates.lng = body.lng
}
if (updates.length === 0) {
if (Object.keys(updates).length === 0) {
const { get } = await getDb()
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row
}
params.push(id)
const { run, get } = await getDb()
await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
const { query, params } = buildUpdateQuery('pois', null, updates)
if (query) {
await run(query, [...params, id])
}
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row

View File

@@ -1,4 +1,4 @@
import { getDb } from '../utils/db.js'
import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { hashPassword } from '../utils/password.js'
@@ -21,18 +21,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
}
const { run, get } = await getDb()
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
const db = await getDb()
return withTransaction(db, async ({ run, get }) => {
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
const id = crypto.randomUUID()
const now = new Date().toISOString()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, identifier, hashPassword(password), role, now, 'local', null, null],
)
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return user
const id = crypto.randomUUID()
const now = new Date().toISOString()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, identifier, hashPassword(password), role, now, 'local', null, null],
)
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return user
})
})

View File

@@ -1,6 +1,7 @@
import { getDb } from '../../utils/db.js'
import { getDb, withTransaction } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
const ROLES = ['admin', 'leader', 'member']
@@ -9,52 +10,52 @@ export default defineEventHandler(async (event) => {
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event)
const { run, get } = await getDb()
const db = await getDb()
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
return withTransaction(db, async ({ run, get }) => {
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
const updates = []
const params = []
const updates = {}
if (body?.role !== undefined) {
const role = body.role
if (!role || !ROLES.includes(role)) {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
}
updates.push('role = ?')
params.push(role)
}
if (user.auth_provider === 'local') {
if (body?.identifier !== undefined) {
const identifier = body.identifier?.trim()
if (!identifier || identifier.length < 1) {
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
if (body?.role !== undefined) {
const role = body.role
if (!role || !ROLES.includes(role)) {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
}
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
updates.push('identifier = ?')
params.push(identifier)
updates.role = role
}
if (body?.password !== undefined && body.password !== '') {
const password = body.password
if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'password cannot be empty' })
if (user.auth_provider === 'local') {
if (body?.identifier !== undefined) {
const identifier = body.identifier?.trim()
if (!identifier || identifier.length < 1) {
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
}
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
updates.identifier = identifier
}
if (body?.password !== undefined && body.password !== '') {
const password = body.password
if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'password cannot be empty' })
}
updates.password_hash = hashPassword(password)
}
updates.push('password_hash = ?')
params.push(hashPassword(password))
}
}
if (updates.length === 0) {
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
}
if (Object.keys(updates).length === 0) {
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
}
params.push(id)
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return updated
const { query, params } = buildUpdateQuery('users', null, updates)
if (query) {
await run(query, [...params, id])
}
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return updated
})
})

View File

@@ -1,6 +1,6 @@
import { getCookie } from 'h3'
import { getDb } from '../utils/db.js'
import { skipAuth } from '../utils/authSkipPaths.js'
import { skipAuth } from '../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
if (skipAuth(event.path)) return

262
server/plugins/cot.js Normal file
View File

@@ -0,0 +1,262 @@
import { createServer as createTcpServer } from 'node:net'
import { createServer as createTlsServer } from 'node:tls'
import { readFileSync, existsSync } from 'node:fs'
import { updateFromCot } from '../utils/cotStore.js'
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../utils/cotParser.js'
import { validateCotAuth } from '../utils/cotAuth.js'
import { getCotSslPaths, getCotPort } from '../utils/cotSsl.js'
import { registerCleanup } from '../utils/shutdown.js'
import { COT_AUTH_TIMEOUT_MS } from '../utils/constants.js'
import { acquire } from '../utils/asyncLock.js'
const serverState = {
tcpServer: null,
tlsServer: null,
}
const relaySet = new Set()
const allSockets = new Set()
const socketBuffers = new WeakMap()
const socketAuthTimeout = new WeakMap()
function clearAuthTimeout(socket) {
const t = socketAuthTimeout.get(socket)
if (t) {
clearTimeout(t)
socketAuthTimeout.delete(socket)
}
}
function removeFromRelay(socket) {
relaySet.delete(socket)
allSockets.delete(socket)
clearAuthTimeout(socket)
socketBuffers.delete(socket)
}
function broadcast(senderSocket, rawMessage) {
for (const s of relaySet) {
if (s !== senderSocket && !s.destroyed && s.writable) {
try {
s.write(rawMessage)
}
catch (err) {
console.error('[cot] Broadcast write error:', err?.message)
}
}
}
}
const createPreview = (payload) => {
try {
const str = payload.toString('utf8')
if (str.startsWith('<')) {
const s = str.length <= 120 ? str : str.slice(0, 120) + '...'
// eslint-disable-next-line no-control-regex -- sanitize control chars for log preview
return s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
}
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
}
catch {
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
}
}
async function processFrame(socket, rawMessage, payload, authenticated) {
const requireAuth = socket._cotRequireAuth !== false
const debug = socket._cotDebug === true
const parsed = parseCotPayload(payload)
if (debug) {
const preview = createPreview(payload)
console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
}
if (!parsed) return
if (parsed.type === 'auth') {
if (authenticated) return
console.log('[cot] auth attempt username=', parsed.username)
// Use lock per socket to prevent concurrent auth attempts
const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
await acquire(socketKey, async () => {
// Re-check authentication state after acquiring lock
if (socket._cotAuthenticated || socket.destroyed) return
try {
const valid = await validateCotAuth(parsed.username, parsed.password)
console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
if (!socket.writable || socket.destroyed) return
if (valid) {
clearAuthTimeout(socket)
relaySet.add(socket)
socket._cotAuthenticated = true
}
else {
socket.destroy()
}
}
catch (err) {
console.log('[cot] auth validation error:', err?.message)
if (!socket.destroyed) socket.destroy()
}
}).catch((err) => {
console.log('[cot] auth lock error:', err?.message)
if (!socket.destroyed) socket.destroy()
})
return
}
if (parsed.type === 'cot') {
if (requireAuth && !authenticated) {
socket.destroy()
return
}
updateFromCot(parsed).catch((err) => {
console.error('[cot] Error updating from CoT:', err?.message)
})
if (authenticated) broadcast(socket, rawMessage)
}
}
const parseFrame = (buf) => {
const takResult = parseTakStreamFrame(buf)
if (takResult) return { result: takResult, frameType: 'tak' }
if (buf[0] === 0x3C) {
const xmlResult = parseTraditionalXmlFrame(buf)
if (xmlResult) return { result: xmlResult, frameType: 'traditional' }
}
return { result: null, frameType: null }
}
const processBufferedData = async (socket, buf, authenticated) => {
if (buf.length === 0) return buf
const { result, frameType } = parseFrame(buf)
if (result && socket._cotDebug) {
console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
}
if (!result) return buf
const { payload, bytesConsumed } = result
const rawMessage = buf.subarray(0, bytesConsumed)
await processFrame(socket, rawMessage, payload, authenticated)
if (socket.destroyed) return null
const remainingBuf = buf.subarray(bytesConsumed)
socketBuffers.set(socket, remainingBuf)
return processBufferedData(socket, remainingBuf, authenticated)
}
async function onData(socket, data) {
const existingBuf = socketBuffers.get(socket)
const buf = Buffer.concat([existingBuf || Buffer.alloc(0), data])
socketBuffers.set(socket, buf)
const authenticated = Boolean(socket._cotAuthenticated)
if (socket._cotDebug && buf.length > 0 && !socket._cotFirstChunkLogged) {
socket._cotFirstChunkLogged = true
const hex = buf.subarray(0, Math.min(80, buf.length)).toString('hex')
console.log('[cot] first chunk len=', buf.length, 'first bytes (hex):', hex, 'starts with 0xBF:', buf[0] === 0xBF, 'starts with <:', buf[0] === 0x3C)
}
await processBufferedData(socket, buf, authenticated)
}
function setupSocket(socket, tls = false) {
const remote = socket.remoteAddress || 'unknown'
console.log('[cot] client connected', tls ? '(TLS)' : '(TCP)', 'from', remote)
allSockets.add(socket)
const config = useRuntimeConfig()
socket._cotDebug = Boolean(config.cotDebug)
socket._cotRequireAuth = config.cotRequireAuth !== false
if (socket._cotRequireAuth) {
const timeout = setTimeout(() => {
if (!socket._cotAuthenticated && !socket.destroyed) {
console.log('[cot] auth timeout, closing connection from', remote)
socket.destroy()
}
}, COT_AUTH_TIMEOUT_MS)
socketAuthTimeout.set(socket, timeout)
}
else {
socket._cotAuthenticated = true
relaySet.add(socket)
}
socket.on('data', data => onData(socket, data))
socket.on('error', (err) => {
console.error('[cot] Socket error:', err?.message)
})
socket.on('close', () => {
console.log('[cot] client disconnected', socket._cotAuthenticated ? '(was authenticated)' : '', 'from', remote)
removeFromRelay(socket)
})
}
function startCotServers() {
const config = useRuntimeConfig()
const { certPath, keyPath } = getCotSslPaths(config) || {}
const hasTls = certPath && keyPath && existsSync(certPath) && existsSync(keyPath)
const port = getCotPort()
try {
if (hasTls) {
const tlsOpts = {
cert: readFileSync(certPath),
key: readFileSync(keyPath),
rejectUnauthorized: false,
}
serverState.tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
serverState.tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
serverState.tlsServer.listen(port, '0.0.0.0', () => {
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (TLS) - use this port in ATAK/iTAK and enable SSL')
})
}
else {
serverState.tcpServer = createTcpServer(socket => setupSocket(socket, false))
serverState.tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
serverState.tcpServer.listen(port, '0.0.0.0', () => {
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (plain TCP) - use this port in ATAK/iTAK with SSL disabled')
})
}
}
catch (err) {
console.error('[cot] Failed to start CoT server:', err?.message)
if (err?.code === 'EADDRINUSE') {
console.error('[cot] Port', port, 'is already in use. Stop the other process or set COT_PORT to a different port.')
}
}
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('ready', startCotServers)
// Start immediately so CoT is up before first request in dev; ready may fire late in some setups.
setImmediate(startCotServers)
const cleanupServers = () => {
if (serverState.tcpServer) {
serverState.tcpServer.close()
serverState.tcpServer = null
}
if (serverState.tlsServer) {
serverState.tlsServer.close()
serverState.tlsServer = null
}
}
const cleanupSockets = () => {
for (const s of allSockets) {
try {
s.destroy()
}
catch {
/* ignore */
}
}
allSockets.clear()
relaySet.clear()
}
registerCleanup(async () => {
cleanupSockets()
cleanupServers()
})
nitroApp.hooks.hook('close', async () => {
cleanupSockets()
cleanupServers()
})
})

View File

@@ -1,6 +1,7 @@
import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
import { registerCleanup } from '../utils/shutdown.js'
function parseCookie(cookieHeader) {
const cookies = {}
@@ -79,8 +80,15 @@ export default defineNitroPlugin((nitroApp) => {
callback(false, 401, 'Unauthorized')
return
}
// Store user_id in request for later use
// Get user role for authorization checks
const user = await get('SELECT id, role FROM users WHERE id = ?', [session.user_id])
if (!user) {
callback(false, 401, 'Unauthorized')
return
}
// Store user_id and role in request for later use
info.req.userId = session.user_id
info.req.userRole = user.role
callback(true)
}
catch (err) {
@@ -92,7 +100,8 @@ export default defineNitroPlugin((nitroApp) => {
wss.on('connection', (ws, req) => {
const userId = req.userId
if (!userId) {
const userRole = req.userRole
if (!userId || !userRole) {
ws.close(1008, 'Unauthorized')
return
}
@@ -109,6 +118,20 @@ export default defineNitroPlugin((nitroApp) => {
return
}
// Verify user has access to this session (authorization check per message)
const { getLiveSession } = await import('../utils/liveSessions.js')
const session = getLiveSession(sessionId)
if (!session) {
ws.send(JSON.stringify({ error: 'Session not found' }))
return
}
// Only session owner or admin/leader can access the session
if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
ws.send(JSON.stringify({ error: 'Forbidden' }))
return
}
// Track session connection
if (currentSessionId !== sessionId) {
if (currentSessionId) {
@@ -142,6 +165,13 @@ export default defineNitroPlugin((nitroApp) => {
})
console.log('[websocket] WebSocket server started on /ws')
registerCleanup(async () => {
if (wss) {
wss.close()
wss = null
}
})
})
nitroApp.hooks.hook('close', () => {

View File

@@ -1 +1,9 @@
export default defineEventHandler(() => ({ status: 'ready' }))
import { healthCheck } from '../../utils/db.js'
export default defineEventHandler(async () => {
const health = await healthCheck()
if (!health.healthy) {
throw createError({ statusCode: 503, message: 'Database not ready' })
}
return { status: 'ready' }
})

47
server/utils/asyncLock.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* Async lock utility - Promise-based mutex per key.
* Ensures only one async operation executes per key at a time.
*/
const locks = new Map()
/**
* Get or create a queue for a lock key.
* @param {string} lockKey - Lock key
* @returns {Promise<any>} Existing or new queue promise
*/
const getOrCreateQueue = (lockKey) => {
const existingQueue = locks.get(lockKey)
if (existingQueue) return existingQueue
const newQueue = Promise.resolve()
locks.set(lockKey, newQueue)
return newQueue
}
/**
* Acquire a lock for a key and execute callback.
* Only one callback per key executes at a time.
* @param {string} key - Lock key
* @param {Function} callback - Async function to execute
* @returns {Promise<any>} Result of callback
*/
export async function acquire(key, callback) {
const lockKey = String(key)
const queue = getOrCreateQueue(lockKey)
const next = queue.then(() => callback()).finally(() => {
if (locks.get(lockKey) === next) {
locks.delete(lockKey)
}
})
locks.set(lockKey, next)
return next
}
/**
* Clear all locks (for testing).
*/
export function clearLocks() {
locks.clear()
}

View File

@@ -1,5 +0,0 @@
export function getAuthConfig() {
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
return Object.freeze({ oidc: { enabled: hasOidc, label } })
}

View File

@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
return user
}
// Auth path utilities
export const SKIP_PATHS = Object.freeze([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
])
export const PROTECTED_PATH_PREFIXES = Object.freeze([
'/api/cameras',
'/api/devices',
'/api/live',
'/api/me',
'/api/pois',
'/api/users',
])
export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
}

View File

@@ -1,23 +0,0 @@
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
export const SKIP_PATHS = Object.freeze([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
])
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
export const PROTECTED_PATH_PREFIXES = Object.freeze([
'/api/cameras',
'/api/devices',
'/api/live',
'/api/me',
'/api/pois',
'/api/users',
])
export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
}

View File

@@ -1,26 +0,0 @@
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const generateRandomPassword = () =>
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
export async function bootstrapAdmin(run, get) {
const row = await get('SELECT COUNT(*) as n FROM users')
if (row?.n !== 0) return
const email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : 'admin'
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}

30
server/utils/constants.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* Application constants with environment variable support.
*/
// Timeouts (milliseconds)
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
// Ports
export const COT_PORT = Number(process.env.COT_PORT) || 8089
export const WEBSOCKET_PATH = process.env.WEBSOCKET_PATH || '/ws'
// Limits
export const MAX_PAYLOAD_BYTES = Number(process.env.MAX_PAYLOAD_BYTES) || 64 * 1024
export const MAX_STRING_LENGTH = Number(process.env.MAX_STRING_LENGTH) || 1000
export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) || 255
// Mediasoup
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
// Session
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
export function getSessionMaxAgeDays() {
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
}

25
server/utils/cotAuth.js Normal file
View File

@@ -0,0 +1,25 @@
import { getDb } from './db.js'
import { verifyPassword } from './password.js'
/**
* Validate CoT auth: local users use password_hash; OIDC users use cot_password_hash (ATAK password).
* @param {string} identifier - KestrelOS identifier (username)
* @param {string} password - Plain password from CoT auth
* @returns {Promise<boolean>} True if valid
*/
export async function validateCotAuth(identifier, password) {
const id = typeof identifier === 'string' ? identifier.trim() : ''
if (!id || typeof password !== 'string') return false
const { get } = await getDb()
const user = await get(
'SELECT auth_provider, password_hash, cot_password_hash FROM users WHERE identifier = ?',
[id],
)
if (!user) return false
const hash = user.auth_provider === 'local' ? user.password_hash : user.cot_password_hash
if (!hash) return false
return verifyPassword(password, hash)
}

151
server/utils/cotParser.js Normal file
View File

@@ -0,0 +1,151 @@
import { XMLParser } from 'fast-xml-parser'
import { MAX_PAYLOAD_BYTES } from './constants.js'
// CoT protocol detection constants
export const COT_FIRST_BYTE_TAK = 0xBF
export const COT_FIRST_BYTE_XML = 0x3C
/** @param {number} byte - First byte of stream. @returns {boolean} */
export function isCotFirstByte(byte) {
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
}
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
/**
* @param {Buffer} buf
* @param {number} offset
* @param {number} value - Accumulated value
* @param {number} shift - Current bit shift
* @param {number} bytesRead - Bytes consumed so far
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
*/
function readVarint(buf, offset, value = 0, shift = 0, bytesRead = 0) {
if (offset + bytesRead >= buf.length) return { value, bytesRead }
const b = buf[offset + bytesRead]
const newValue = value + ((b & 0x7F) << shift)
const newBytesRead = bytesRead + 1
if ((b & 0x80) === 0) return { value: newValue, bytesRead: newBytesRead }
const newShift = shift + 7
if (newShift > 28) return { value: 0, bytesRead: 0 }
return readVarint(buf, offset, newValue, newShift, newBytesRead)
}
/**
* TAK stream frame: 0xBF, varint length, payload.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
*/
export function parseTakStreamFrame(buf) {
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
const { value: length, bytesRead } = readVarint(buf, 1)
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
const bytesConsumed = 1 + bytesRead + length
if (buf.length < bytesConsumed) return null
return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed }
}
/**
* Traditional CoT: one XML message delimited by </event>.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
*/
export function parseTraditionalXmlFrame(buf) {
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
if (idx === -1) return null
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
if (bytesConsumed > MAX_PAYLOAD_BYTES) return null
return { payload: buf.subarray(0, bytesConsumed), bytesConsumed }
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
ignoreDeclaration: true,
ignorePiTags: true,
processEntities: false, // Disable entity expansion to prevent XML bomb attacks
maxAttributes: 100,
parseAttributeValue: false,
trimValues: true,
parseTrueNumberOnly: false,
arrayMode: false,
stopNodes: [], // Could add depth limit here if needed
})
/**
* Case-insensitive key lookup in nested object.
* @returns {unknown} Found value or undefined.
*/
function findInObject(obj, key) {
if (!obj || typeof obj !== 'object') return undefined
const k = key.toLowerCase()
for (const [name, val] of Object.entries(obj)) {
if (name.toLowerCase() === k) return val
if (typeof val === 'object' && val !== null) {
const found = findInObject(val, key)
if (found !== undefined) return found
}
}
return undefined
}
/**
* Extract { username, password } from detail.auth (or __auth / credentials).
* @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid.
*/
function extractAuth(parsed) {
const detail = findInObject(parsed, 'detail')
if (!detail || typeof detail !== 'object') return null
const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials')
if (!auth || typeof auth !== 'object') return null
const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username
const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password
if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null
return { username: username.trim(), password }
}
/**
* Parse CoT XML payload into auth or position. Does not mutate payload.
* @param {Buffer} payload - UTF-8 XML
* @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null.
*/
export function parseCotPayload(payload) {
if (!payload?.length) return null
const str = payload.toString('utf8').trim()
if (!str.startsWith('<')) return null
try {
const parsed = xmlParser.parse(str)
const event = findInObject(parsed, 'event')
if (!event || typeof event !== 'object') return null
const auth = extractAuth(parsed)
if (auth) return { type: 'auth', username: auth.username, password: auth.password }
const uid = String(event['@_uid'] ?? event.uid ?? '')
const eventType = String(event['@_type'] ?? event.type ?? '')
const point = findInObject(parsed, 'point') ?? findInObject(event, 'point')
const extractCoords = (pt) => {
if (!pt || typeof pt !== 'object') return { lat: Number.NaN, lng: Number.NaN }
return {
lat: Number(pt['@_lat'] ?? pt.lat),
lng: Number(pt['@_lon'] ?? pt.lon ?? pt['@_lng'] ?? pt.lng),
}
}
const { lat, lng } = extractCoords(point)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const detail = findInObject(parsed, 'detail')
const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null
const callsign = contact && typeof contact === 'object'
? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign'])
: ''
const label = typeof callsign === 'string' ? callsign.trim() || uid : uid
return { type: 'cot', id: uid, lat, lng, label, eventType }
}
catch {
return null
}
}

73
server/utils/cotSsl.js Normal file
View File

@@ -0,0 +1,73 @@
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
/** Default password for the CoT trust store (document in atak-itak.md). */
export const TRUSTSTORE_PASSWORD = 'kestrelos'
/** Default CoT server port. */
export const DEFAULT_COT_PORT = 8089
/**
* CoT port from env or default.
* @returns {number} Port number (COT_PORT env or DEFAULT_COT_PORT).
*/
export function getCotPort() {
return Number(process.env.COT_PORT ?? DEFAULT_COT_PORT)
}
/** Message when an endpoint requires TLS but server is not using it. */
export const COT_TLS_REQUIRED_MESSAGE = 'Only available when the server runs with SSL (e.g. .dev-certs or COT_SSL_*).'
/**
* Resolve CoT server TLS cert and key paths (for plugin and API).
* @param {{ cotSslCert?: string, cotSslKey?: string }} [config] - Runtime config (optional)
* @returns {{ certPath: string, keyPath: string } | null} Paths when TLS is configured, else null.
*/
export function getCotSslPaths(config = {}) {
if (process.env.COT_SSL_CERT && process.env.COT_SSL_KEY) {
return { certPath: process.env.COT_SSL_CERT, keyPath: process.env.COT_SSL_KEY }
}
if (config.cotSslCert && config.cotSslKey) {
return { certPath: config.cotSslCert, keyPath: config.cotSslKey }
}
const candidates = [
join(process.cwd(), '.dev-certs', 'cert.pem'),
join(__dirname, '../../.dev-certs', 'cert.pem'),
]
for (const certPath of candidates) {
const keyPath = certPath.replace('cert.pem', 'key.pem')
if (existsSync(certPath) && existsSync(keyPath)) {
return { certPath, keyPath }
}
}
return null
}
/**
* Build a P12 trust store from a PEM cert path (for truststore download and server package).
* @param {string} certPath - Path to cert.pem
* @param {string} password - P12 password
* @returns {Buffer} P12 buffer
* @throws {Error} If openssl fails
*/
export function buildP12FromCertPath(certPath, password) {
const outPath = join(tmpdir(), `kestrelos-cot-p12-${Date.now()}.p12`)
try {
execSync(
`openssl pkcs12 -export -nokeys -in "${certPath}" -out "${outPath}" -passout pass:${password}`,
{ stdio: 'pipe' },
)
const p12 = readFileSync(outPath)
unlinkSync(outPath)
return p12
}
catch (err) {
if (existsSync(outPath)) unlinkSync(outPath)
throw err
}
}

71
server/utils/cotStore.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* In-memory CoT entity store: upsert by id, prune on read by TTL.
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
*/
import { acquire } from './asyncLock.js'
import { COT_ENTITY_TTL_MS } from './constants.js'
const entities = new Map()
/**
* Upsert entity by id. Input is not mutated; stored value is a new object.
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
*/
export async function updateFromCot(parsed) {
if (!parsed || typeof parsed.id !== 'string') return
const lat = Number(parsed.lat)
const lng = Number(parsed.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
await acquire(`cot-${parsed.id}`, async () => {
const now = Date.now()
const existing = entities.get(parsed.id)
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
entities.set(parsed.id, {
id: parsed.id,
lat,
lng,
label,
type,
updatedAt: now,
})
})
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {number} [ttlMs]
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
*/
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
return acquire('cot-prune', async () => {
const now = Date.now()
const active = []
const expired = []
for (const entity of entities.values()) {
if (now - entity.updatedAt <= ttlMs) {
active.push({
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
updatedAt: entity.updatedAt,
})
}
else {
expired.push(entity.id)
}
}
for (const id of expired) entities.delete(id)
return active
})
}
/** Clear store (tests only). */
export function clearCotStore() {
entities.clear()
}

View File

@@ -2,12 +2,15 @@ import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { promisify } from 'node:util'
import { bootstrapAdmin } from './bootstrap.js'
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
import { registerCleanup } from './shutdown.js'
const require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3')
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const sqlite3 = requireFromRoot('sqlite3')
const SCHEMA_VERSION = 3
const SCHEMA_VERSION = 4
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -111,6 +114,12 @@ const migrateToV3 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
const migrateToV4 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'cot_password_hash')) return
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -122,6 +131,10 @@ const runMigrations = async (run, all, get) => {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
if (version < 4) {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
}
const initDb = async (db, run, all, get) => {
@@ -140,7 +153,29 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.pois)
await run(SCHEMA.devices)
if (!testPath) await bootstrapAdmin(run, get)
if (!testPath) {
// Bootstrap admin user on first run
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const generateRandomPassword = () =>
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
const row = await get('SELECT COUNT(*) as n FROM users')
if (row?.n === 0) {
const email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : 'admin'
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}
}
}
export async function getDb() {
@@ -167,9 +202,91 @@ export async function getDb() {
}
dbInstance = { db, run, all, get }
registerCleanup(async () => {
if (dbInstance) {
try {
await new Promise((resolve, reject) => {
dbInstance.db.close((err) => {
if (err) reject(err)
else resolve()
})
})
}
catch (error) {
console.error('[db] Error closing database during shutdown:', error?.message)
}
dbInstance = null
}
})
return dbInstance
}
/**
* Health check for database connection.
* @returns {Promise<{ healthy: boolean, error?: string }>} Health status
*/
export async function healthCheck() {
try {
const db = await getDb()
await db.get('SELECT 1')
return { healthy: true }
}
catch (error) {
return {
healthy: false,
error: error?.message || String(error),
}
}
}
/**
* Database connection model documentation:
*
* KestrelOS uses SQLite with WAL (Write-Ahead Logging) mode for concurrent access.
* - Single connection instance shared across all requests (singleton pattern)
* - WAL mode allows multiple readers and one writer concurrently
* - Connection is initialized on first getDb() call and reused thereafter
* - Busy timeout is set to 5000ms to handle concurrent access gracefully
* - Transactions are supported via withTransaction() helper
*
* Concurrency considerations:
* - SQLite with WAL handles concurrent reads efficiently
* - Writes are serialized (one at a time)
* - For high write loads, consider migrating to PostgreSQL
* - Current model is suitable for moderate traffic (< 100 req/sec)
*
* Connection lifecycle:
* - Created on first getDb() call
* - Persists for application lifetime
* - Closed during graceful shutdown
* - Test path can be set via setDbPathForTest() for testing
*/
/**
* Execute a callback within a database transaction.
* Automatically commits on success or rolls back on error.
* @param {object} db - Database instance from getDb()
* @param {Function} callback - Async function receiving { run, all, get }
* @returns {Promise<any>} Result of callback
*/
export async function withTransaction(db, callback) {
const { run } = db
await run('BEGIN TRANSACTION')
try {
const result = await callback(db)
await run('COMMIT')
return result
}
catch (error) {
await run('ROLLBACK').catch(() => {
// Ignore rollback errors
})
throw error
}
}
export function closeDb() {
if (!dbInstance) return
try {

View File

@@ -1,47 +1,79 @@
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
import { acquire } from './asyncLock.js'
import { LIVE_SESSION_TTL_MS } from './constants.js'
const TTL_MS = 60_000
const sessions = new Map()
export const createSession = (userId, label = '') => {
const id = crypto.randomUUID()
const session = {
id,
userId,
label: (label || 'Live').trim() || 'Live',
lat: 0,
lng: 0,
updatedAt: Date.now(),
routerId: null,
producerId: null,
transportId: null,
}
sessions.set(id, session)
return session
export const createSession = async (userId, label = '') => {
return acquire(`session-create-${userId}`, async () => {
const id = crypto.randomUUID()
const session = {
id,
userId,
label: (label || 'Live').trim() || 'Live',
lat: 0,
lng: 0,
updatedAt: Date.now(),
routerId: null,
producerId: null,
transportId: null,
}
sessions.set(id, session)
return session
})
}
/**
* Atomically get existing active session or create new one for user.
* @param {string} userId - User ID
* @param {string} label - Session label
* @returns {Promise<object>} Session object
*/
export const getOrCreateSession = async (userId, label = '') => {
return acquire(`session-get-or-create-${userId}`, async () => {
const now = Date.now()
for (const s of sessions.values()) {
if (s.userId === userId && now - s.updatedAt <= LIVE_SESSION_TTL_MS) {
return s
}
}
return await createSession(userId, label)
})
}
export const getLiveSession = id => sessions.get(id)
export const getActiveSessionByUserId = (userId) => {
const now = Date.now()
for (const s of sessions.values()) {
if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s
}
export const getActiveSessionByUserId = async (userId) => {
return acquire(`session-get-${userId}`, async () => {
const now = Date.now()
for (const s of sessions.values()) {
if (s.userId === userId && now - s.updatedAt <= LIVE_SESSION_TTL_MS) return s
}
})
}
export const updateLiveSession = (id, updates) => {
const session = sessions.get(id)
if (!session) return
const now = Date.now()
if (Number.isFinite(updates.lat)) session.lat = updates.lat
if (Number.isFinite(updates.lng)) session.lng = updates.lng
if (updates.routerId !== undefined) session.routerId = updates.routerId
if (updates.producerId !== undefined) session.producerId = updates.producerId
if (updates.transportId !== undefined) session.transportId = updates.transportId
session.updatedAt = now
export const updateLiveSession = async (id, updates) => {
return acquire(`session-update-${id}`, async () => {
const session = sessions.get(id)
if (!session) {
throw new Error('Session not found')
}
const now = Date.now()
if (Number.isFinite(updates.lat)) session.lat = updates.lat
if (Number.isFinite(updates.lng)) session.lng = updates.lng
if (updates.routerId !== undefined) session.routerId = updates.routerId
if (updates.producerId !== undefined) session.producerId = updates.producerId
if (updates.transportId !== undefined) session.transportId = updates.transportId
session.updatedAt = now
return session
})
}
export const deleteLiveSession = id => sessions.delete(id)
export const deleteLiveSession = async (id) => {
await acquire(`session-delete-${id}`, async () => {
sessions.delete(id)
})
}
export const clearSessions = () => sessions.clear()
@@ -62,31 +94,33 @@ const cleanupSession = async (session) => {
}
export const getActiveSessions = async () => {
const now = Date.now()
const active = []
const expired = []
return acquire('get-active-sessions', async () => {
const now = Date.now()
const active = []
const expired = []
for (const session of sessions.values()) {
if (now - session.updatedAt <= TTL_MS) {
active.push({
id: session.id,
userId: session.userId,
label: session.label,
lat: session.lat,
lng: session.lng,
updatedAt: session.updatedAt,
hasStream: Boolean(session.producerId),
})
for (const session of sessions.values()) {
if (now - session.updatedAt <= LIVE_SESSION_TTL_MS) {
active.push({
id: session.id,
userId: session.userId,
label: session.label,
lat: session.lat,
lng: session.lng,
updatedAt: session.updatedAt,
hasStream: Boolean(session.producerId),
})
}
else {
expired.push(session)
}
}
else {
expired.push(session)
for (const session of expired) {
await cleanupSession(session)
sessions.delete(session.id)
}
}
for (const session of expired) {
await cleanupSession(session)
sessions.delete(session.id)
}
return active
return active
})
}

84
server/utils/logger.js Normal file
View File

@@ -0,0 +1,84 @@
/**
* Structured logger with request context support.
* Uses AsyncLocalStorage to provide request-scoped context that's automatically isolated per async context.
*/
import { AsyncLocalStorage } from 'node:async_hooks'
const asyncLocalStorage = new AsyncLocalStorage()
/**
* Run a function with logger context. Context is automatically isolated per async execution.
* @param {string} reqId - Request ID
* @param {string|null} uId - User ID (optional)
* @param {Function} fn - Function to run with context
* @returns {Promise<any>} Result of the function
*/
export function runWithContext(reqId, uId, fn) {
return asyncLocalStorage.run({ requestId: reqId, userId: uId }, fn)
}
/**
* Set context for the current async context. Use runWithContext() instead for proper isolation.
* @deprecated Use runWithContext() instead for proper async context isolation
* @param {string} reqId - Request ID
* @param {string|null} uId - User ID (optional)
*/
export function setContext(reqId, uId = null) {
const store = asyncLocalStorage.getStore()
if (store) {
store.requestId = reqId
store.userId = uId
}
}
/**
* Clear context for the current async context.
* @deprecated Context is automatically cleared when async context ends. Use runWithContext() instead.
*/
export function clearContext() {
const store = asyncLocalStorage.getStore()
if (store) {
store.requestId = null
store.userId = null
}
}
function getContext() {
return asyncLocalStorage.getStore() || { requestId: null, userId: null }
}
function formatMessage(level, message, context = {}) {
const { requestId, userId } = getContext()
const timestamp = new Date().toISOString()
const ctx = {
timestamp,
level,
requestId,
...(userId && { userId }),
...context,
}
return `[${level.toUpperCase()}] ${JSON.stringify({ message, ...ctx })}`
}
export function info(message, context = {}) {
console.log(formatMessage('info', message, context))
}
export function error(message, context = {}) {
const ctx = { ...context }
if (context.error && context.error.stack) {
ctx.stack = context.error.stack
}
console.error(formatMessage('error', message, ctx))
}
export function warn(message, context = {}) {
console.warn(formatMessage('warn', message, context))
}
export function debug(message, context = {}) {
if (process.env.NODE_ENV === 'development') {
console.debug(formatMessage('debug', message, context))
}
}

View File

@@ -1,5 +1,7 @@
import os from 'node:os'
import mediasoup from 'mediasoup'
import { acquire } from './asyncLock.js'
import { MEDIASOUP_RTC_MIN_PORT, MEDIASOUP_RTC_MAX_PORT } from './constants.js'
let worker = null
const routers = new Map()
@@ -17,22 +19,25 @@ export const getWorker = async () => {
worker = await mediasoup.createWorker({
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp'],
rtcMinPort: 40000,
rtcMaxPort: 49999,
rtcMinPort: MEDIASOUP_RTC_MIN_PORT,
rtcMaxPort: MEDIASOUP_RTC_MAX_PORT,
})
worker.on('died', () => {
console.error('[mediasoup] Worker died, exiting')
process.exit(1)
worker.on('died', async (error) => {
console.error('[mediasoup] Worker died:', error?.message || String(error))
const { graceful } = await import('./shutdown.js')
await graceful(error || new Error('Mediasoup worker died'))
})
return worker
}
export const getRouter = async (sessionId) => {
const existing = routers.get(sessionId)
if (existing) return existing
const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
routers.set(sessionId, router)
return router
return acquire(`router-${sessionId}`, async () => {
const existing = routers.get(sessionId)
if (existing) return existing
const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
routers.set(sessionId, router)
return router
})
}
const isIPv4 = (host) => {
@@ -64,34 +69,36 @@ const resolveAnnouncedIp = (requestHost) => {
}
export const createTransport = async (router, requestHost = undefined) => {
const announcedIp = resolveAnnouncedIp(requestHost)
const listenIps = announcedIp
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
: [{ ip: '127.0.0.1' }]
return acquire(`transport-${router.id}`, async () => {
const announcedIp = resolveAnnouncedIp(requestHost)
const listenIps = announcedIp
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
: [{ ip: '127.0.0.1' }]
const transport = await router.createWebRtcTransport({
listenIps,
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000,
}).catch((err) => {
console.error('[mediasoup] Transport creation failed:', err)
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
const transport = await router.createWebRtcTransport({
listenIps,
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000,
}).catch((err) => {
console.error('[mediasoup] Transport creation failed:', err)
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
})
transports.set(transport.id, transport)
transport.on('close', () => transports.delete(transport.id))
return {
transport,
params: {
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
},
}
})
transports.set(transport.id, transport)
transport.on('close', () => transports.delete(transport.id))
return {
transport,
params: {
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
},
}
}
export const getTransport = transportId => transports.get(transportId)

View File

@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
const CACHE_TTL_MS = 60 * 60 * 1000
const configCache = new Map()
// Auth configuration
export function getAuthConfig() {
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
return Object.freeze({ oidc: { enabled: hasOidc, label } })
}
function getRedirectUri() {
const explicit
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''

View File

@@ -1 +0,0 @@
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])

View File

@@ -0,0 +1,28 @@
/**
* Query builder for safe dynamic UPDATE queries with column whitelist validation.
* Prevents SQL injection by validating column names against allowed sets.
*/
const ALLOWED_COLUMNS = {
devices: new Set(['name', 'device_type', 'vendor', 'lat', 'lng', 'stream_url', 'source_type', 'config']),
users: new Set(['role', 'identifier', 'password_hash']),
pois: new Set(['label', 'icon_type', 'lat', 'lng']),
}
export function buildUpdateQuery(table, allowedColumns, updates) {
if (!ALLOWED_COLUMNS[table]) throw new Error(`Unknown table: ${table}`)
const columns = allowedColumns || ALLOWED_COLUMNS[table]
const clauses = []
const params = []
for (const [column, value] of Object.entries(updates)) {
if (!columns.has(column)) throw new Error(`Invalid column: ${column} for table: ${table}`)
clauses.push(`${column} = ?`)
params.push(value)
}
if (clauses.length === 0) return { query: '', params: [] }
return { query: `UPDATE ${table} SET ${clauses.join(', ')} WHERE id = ?`, params }
}
export function getAllowedColumns(table) {
return ALLOWED_COLUMNS[table] || new Set()
}

View File

@@ -1,6 +0,0 @@
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
export function getSessionMaxAgeDays() {
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
}

78
server/utils/shutdown.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* Graceful shutdown handler - registers cleanup functions and handles shutdown signals.
*/
import { SHUTDOWN_TIMEOUT_MS } from './constants.js'
const cleanupFunctions = []
const shutdownState = {
isShuttingDown: false,
}
export function clearCleanup() {
cleanupFunctions.length = 0
shutdownState.isShuttingDown = false
}
export function registerCleanup(fn) {
if (typeof fn !== 'function') throw new TypeError('Cleanup function must be a function')
cleanupFunctions.push(fn)
}
const executeCleanupFunction = async (fn, index) => {
try {
await fn()
}
catch (error) {
console.error(`[shutdown] Cleanup function ${index} failed:`, error?.message || String(error))
}
}
const executeCleanupReverse = async (functions, index = functions.length - 1) => {
if (index < 0) return
await executeCleanupFunction(functions[index], index)
return executeCleanupReverse(functions, index - 1)
}
async function executeCleanup() {
if (shutdownState.isShuttingDown) return
shutdownState.isShuttingDown = true
await executeCleanupReverse(cleanupFunctions)
}
export async function graceful(error) {
if (error) {
console.error('[shutdown] Shutting down due to error:', error?.message || String(error))
if (error.stack) console.error('[shutdown] Stack trace:', error.stack)
}
else {
console.log('[shutdown] Initiating graceful shutdown')
}
const timeout = setTimeout(() => {
console.error('[shutdown] Shutdown timeout exceeded, forcing exit')
process.exit(1)
}, SHUTDOWN_TIMEOUT_MS)
try {
await executeCleanup()
clearTimeout(timeout)
console.log('[shutdown] Cleanup complete')
process.exit(error ? 1 : 0)
}
catch (err) {
clearTimeout(timeout)
console.error('[shutdown] Error during cleanup:', err?.message || String(err))
process.exit(1)
}
}
export function initShutdownHandlers() {
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
console.log(`[shutdown] Received ${signal}`)
graceful().catch((err) => {
console.error('[shutdown] Error in graceful shutdown:', err)
process.exit(1)
})
})
}
}

150
server/utils/validation.js Normal file
View File

@@ -0,0 +1,150 @@
/**
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
*/
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
// Constants
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
// Sanitization functions
const IDENTIFIER_REGEX = /^\w+$/
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
if (typeof str !== 'string') return ''
const trimmed = str.trim()
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
}
export function sanitizeIdentifier(str) {
if (typeof str !== 'string') return ''
const trimmed = str.trim()
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
}
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
return sanitizeString(str, maxLength)
}
const ROLES = ['admin', 'leader', 'member']
const validateNumber = (value, field) => {
const num = Number(value)
return Number.isFinite(num) ? { valid: true, value: num } : { valid: false, error: `${field} must be a finite number` }
}
const validateEnum = (value, allowed, field) => allowed.includes(value) ? { valid: true, value } : { valid: false, error: `Invalid ${field}` }
const handleField = (d, field, handler, updates, errors, outputField = null) => {
if (d[field] !== undefined) {
const result = handler(d[field])
if (result.valid) updates[outputField || field] = result.value
else errors.push(result.error)
}
}
export function validateDevice(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const latCheck = validateNumber(d.lat, 'lat')
const lngCheck = validateNumber(d.lng, 'lng')
if (!latCheck.valid || !lngCheck.valid) errors.push('lat and lng required as finite numbers')
if (errors.length > 0) return { valid: false, errors }
return {
valid: true,
errors: [],
data: {
name: sanitizeString(d.name, 1000),
device_type: validateEnum(d.device_type, DEVICE_TYPES, 'device_type').value || 'feed',
vendor: d.vendor !== undefined ? sanitizeString(d.vendor, 255) : null,
lat: latCheck.value,
lng: lngCheck.value,
stream_url: typeof d.stream_url === 'string' ? sanitizeString(d.stream_url, 2000) : '',
source_type: validateEnum(d.source_type, SOURCE_TYPES, 'source_type').value || 'mjpeg',
config: d.config == null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)),
},
}
}
export function validateUpdateDevice(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.name !== undefined) updates.name = sanitizeString(d.name, 1000)
handleField(d, 'device_type', v => validateEnum(v, DEVICE_TYPES, 'device_type'), updates, errors)
if (d.vendor !== undefined) updates.vendor = d.vendor === null || d.vendor === '' ? null : sanitizeString(d.vendor, 255)
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
if (d.stream_url !== undefined) updates.stream_url = sanitizeString(d.stream_url, 2000)
handleField(d, 'source_type', v => validateEnum(v, SOURCE_TYPES, 'source_type'), updates, errors)
if (d.config !== undefined) updates.config = d.config === null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config))
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}
export function validateUser(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const identifier = sanitizeIdentifier(d.identifier)
const password = typeof d.password === 'string' ? d.password : ''
const role = typeof d.role === 'string' ? d.role : ''
if (!identifier) errors.push('identifier required')
if (!password) errors.push('password required')
if (!role || !ROLES.includes(role)) errors.push('role must be admin, leader, or member')
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: { identifier, password, role: role || 'member' } }
}
export function validateUpdateUser(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.role !== undefined) {
if (ROLES.includes(d.role)) updates.role = d.role
else errors.push('role must be admin, leader, or member')
}
if (d.identifier !== undefined) {
const identifier = sanitizeIdentifier(d.identifier)
if (!identifier) errors.push('identifier cannot be empty')
else updates.identifier = identifier
}
if (d.password !== undefined && d.password !== '') {
if (typeof d.password !== 'string' || !d.password) errors.push('password cannot be empty')
else updates.password = d.password
}
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}
export function validatePoi(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const latCheck = validateNumber(d.lat, 'lat')
const lngCheck = validateNumber(d.lng, 'lng')
if (!latCheck.valid || !lngCheck.valid) return { valid: false, errors: ['lat and lng required as finite numbers'] }
return {
valid: true,
errors: [],
data: {
lat: latCheck.value,
lng: lngCheck.value,
label: sanitizeLabel(d.label, 500),
icon_type: validateEnum(d.iconType, POI_ICON_TYPES, 'iconType').value || 'pin',
},
}
}
export function validateUpdatePoi(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.label !== undefined) updates.label = sanitizeLabel(d.label, 500)
handleField(d, 'iconType', v => validateEnum(v, POI_ICON_TYPES, 'iconType'), updates, errors, 'icon_type')
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}

View File

@@ -20,7 +20,15 @@ export async function handleWebSocketMessage(userId, sessionId, type, data) {
case 'create-transport': {
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router)
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
try {
await updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
}
catch (err) {
if (err.message === 'Session not found') {
return { error: 'Session not found' }
}
throw err
}
return { type: 'transport-created', data: params }
}
case 'connect-transport': {

54
test/helpers/env.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Functional helpers for test environment management.
* Returns new objects instead of mutating process.env directly.
*/
/**
* Creates a new env object with specified overrides
* @param {Record<string, string | undefined>} overrides - Env vars to set/override
* @returns {Record<string, string>} New env object
*/
export const withEnv = overrides => ({
...process.env,
...Object.fromEntries(
Object.entries(overrides).filter(([, v]) => v !== undefined),
),
})
/**
* Creates a new env object with specified vars removed
* @param {string[]} keys - Env var keys to remove
* @returns {Record<string, string>} New env object
*/
export const withoutEnv = (keys) => {
const result = { ...process.env }
for (const key of keys) {
delete result[key]
}
return result
}
/**
* Executes a function with a temporary env, restoring original after
* @param {Record<string, string | undefined>} env - Temporary env to use
* @param {() => any} fn - Function to execute
* @returns {any} Result of fn()
*/
export const withTemporaryEnv = (env, fn) => {
const original = { ...process.env }
try {
// Set defined values
Object.entries(env).forEach(([key, value]) => {
if (value !== undefined) {
process.env[key] = value
}
else {
delete process.env[key]
}
})
return fn()
}
finally {
process.env = original
}
}

View File

@@ -0,0 +1,59 @@
/**
* Encode a number as varint bytes (little-endian, continuation bit).
* @param {number} value - Value to encode
* @param {number[]} bytes - Accumulated bytes (default empty)
* @returns {number[]} Varint bytes
*/
const encodeVarint = (value, bytes = []) => {
const byte = value & 0x7F
const remaining = value >>> 7
if (remaining === 0) {
return [...bytes, byte]
}
return encodeVarint(remaining, [...bytes, byte | 0x80])
}
/**
* Build a TAK Protocol stream frame: 0xBF, varint payload length, payload.
* @param {string|Buffer} payload - UTF-8 payload (e.g. CoT XML)
* @returns {Buffer} TAK stream frame buffer.
*/
export function buildTakFrame(payload) {
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8')
const varint = encodeVarint(buf.length)
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
}
/**
* Build CoT XML for a position update (event + point + optional contact).
* @param {object} opts - Position options
* @param {string} opts.uid - Entity UID
* @param {number} opts.lat - Latitude
* @param {number} opts.lon - Longitude
* @param {string} [opts.callsign] - Optional callsign
* @param {string} [opts.type] - Optional event type (default a-f-G)
* @returns {string} CoT XML string.
*/
export function buildPositionCotXml({ uid, lat, lon, callsign, type = 'a-f-G' }) {
const contact = callsign ? `<detail><contact callsign="${escapeXml(callsign)}"/></detail>` : ''
return `<event uid="${escapeXml(uid)}" type="${escapeXml(type)}"><point lat="${lat}" lon="${lon}"/>${contact}</event>`
}
/**
* Build CoT XML for auth (username/password).
* @param {object} opts - Auth options
* @param {string} opts.username - Username
* @param {string} opts.password - Password
* @returns {string} CoT XML string.
*/
export function buildAuthCotXml({ username, password }) {
return `<event><detail><auth username="${escapeXml(username)}" password="${escapeXml(password)}"/></detail></event>`
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

View File

@@ -0,0 +1,128 @@
/**
* @vitest-environment node
*
* Integration test: built server on 3000 (API) and 8089 (CoT). Uses temp DB and bootstrap
* user so CoT auth can be asserted (socket stays open on success).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { buildAuthCotXml } from '../helpers/fakeAtakClient.js'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '../..')
const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
{ cwd: projectRoot, stdio: 'pipe' },
)
}
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
clearTimeout(t)
if (res.ok) return baseURL
}
catch {
// try next
}
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
}
beforeAll(async () => {
ensureDevCerts()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
}
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
}
testState.serverProcess = spawn('node', ['.output/server/index.mjs'], {
cwd: projectRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
}, 120000)
afterAll(() => {
if (testState.serverProcess?.pid) {
testState.serverProcess.kill('SIGTERM')
}
})
it('serves health on port 3000', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
catch {
return tryProtocols(protocols.slice(1))
}
}
const res = await tryProtocols(['https', 'http'])
expect(res?.ok).toBe(true)
const body = await res.json()
expect(body).toHaveProperty('status', 'ok')
expect(body).toHaveProperty('endpoints')
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
s.setTimeout(8000, () => reject(new Error('connect timeout')))
})
await new Promise(r => setTimeout(r, 600))
expect(socket.destroyed).toBe(false)
socket.destroy()
})
})

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
describe('shutdown integration', () => {
const testState = {
originalExit: null,
exitCalls: [],
originalOn: null,
}
beforeEach(() => {
clearCleanup()
testState.exitCalls = []
testState.originalExit = process.exit
process.exit = vi.fn((code) => {
testState.exitCalls.push(code)
})
testState.originalOn = process.on
})
afterEach(() => {
process.exit = testState.originalExit
process.on = testState.originalOn
clearCleanup()
})
it('initializes signal handlers', () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = testState.originalOn
})
it('signal handler calls graceful', async () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
const sigtermHandler = handlers.SIGTERM
expect(sigtermHandler).toBeDefined()
await sigtermHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
})
it('signal handler handles graceful error', async () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
const sigintHandler = handlers.SIGINT
vi.spyOn(console, 'error').mockImplementation(() => {})
registerCleanup(async () => {
throw new Error('Force error')
})
await sigintHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
})
it('covers timeout path in graceful', async () => {
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
})
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
expect(testState.exitCalls.length).toBeGreaterThan(0)
})
it('covers graceful catch block', async () => {
registerCleanup(async () => {
throw new Error('Test error')
})
await graceful()
expect(testState.exitCalls.length).toBeGreaterThan(0)
})
})

View File

@@ -2,84 +2,58 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import CameraViewer from '../../app/components/CameraViewer.vue'
const createCamera = (overrides = {}) => ({
id: 't1',
name: 'Test Camera',
streamUrl: 'https://example.com/stream.mjpg',
sourceType: 'mjpeg',
...overrides,
})
describe('CameraViewer (device stream)', () => {
it('renders device name and close button', async () => {
const camera = {
id: 't1',
name: 'Test Camera',
streamUrl: 'https://example.com/stream.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
props: { camera: createCamera({ name: 'Test Camera' }) },
})
expect(wrapper.text()).toContain('Test Camera')
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
})
it('does not set img src for non-http streamUrl', async () => {
const camera = {
id: 't2',
name: 'Bad',
streamUrl: 'javascript:alert(1)',
sourceType: 'mjpeg',
}
it.each([
['javascript:alert(1)', false],
['https://example.com/cam.mjpg', true],
])('handles streamUrl: %s -> img exists: %s', async (streamUrl, shouldExist) => {
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
props: { camera: createCamera({ streamUrl }) },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(false)
})
it('uses safe http streamUrl for img', async () => {
const camera = {
id: 't3',
name: 'OK',
streamUrl: 'https://example.com/cam.mjpg',
sourceType: 'mjpeg',
expect(img.exists()).toBe(shouldExist)
if (shouldExist) {
expect(img.attributes('src')).toBe(streamUrl)
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/cam.mjpg')
})
it('emits close when close button clicked', async () => {
const camera = {
id: 't5',
name: 'Close me',
streamUrl: '',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera() },
})
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('shows stream unavailable when img errors', async () => {
const camera = {
id: 't6',
name: 'Broken',
streamUrl: 'https://example.com/bad.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const img = wrapper.find('img')
await img.trigger('error')
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera({ streamUrl: 'https://example.com/bad.mjpg' }) },
})
await wrapper.find('img').trigger('error')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Stream unavailable')
})
it('renders video element for hls sourceType', async () => {
const camera = {
id: 't7',
name: 'HLS Camera',
streamUrl: 'https://example.com/stream.m3u8',
sourceType: 'hls',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera({ sourceType: 'hls', streamUrl: 'https://example.com/stream.m3u8' }) },
})
expect(wrapper.find('video').exists()).toBe(true)
})
})

View File

@@ -1,58 +1,43 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
const mountDrawer = (props = {}) => {
return mountSuspended(NavDrawer, {
props: { modelValue: true, ...props },
attachTo: document.body,
})
}
describe('NavDrawer', () => {
it('renders navigation links with correct paths', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const links = document.body.querySelectorAll('aside nav a[href]')
const hrefs = [...links].map(a => a.getAttribute('href'))
expect(hrefs).toContain('/')
expect(hrefs).toContain('/account')
expect(hrefs).toContain('/cameras')
expect(hrefs).toContain('/poi')
expect(hrefs).toContain('/members')
expect(hrefs).toContain('/settings')
expect(links.length).toBeGreaterThanOrEqual(6)
beforeEach(() => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
})
it('renders Map and Settings labels', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
expect(document.body.textContent).toContain('Map')
expect(document.body.textContent).toContain('Settings')
it('renders navigation links with correct paths', async () => {
await mountDrawer()
const hrefs = [...document.body.querySelectorAll('aside nav a[href]')].map(a => a.getAttribute('href'))
expect(hrefs).toEqual(expect.arrayContaining(['/', '/account', '/cameras', '/poi', '/members', '/settings']))
})
it.each([
['Map'],
['Settings'],
])('renders %s label', async (label) => {
await mountDrawer()
expect(document.body.textContent).toContain(label)
})
it('emits update:modelValue when close is triggered', async () => {
withAuth()
const wrapper = await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const wrapper = await mountDrawer()
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
await wrapper.vm.close()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('applies active styling for current route', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
await mountDrawer()
const mapLink = document.body.querySelector('aside nav a[href="/"]')
expect(mapLink).toBeTruthy()
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
expect(mapLink?.className).toMatch(/kestrel-accent|border-kestrel-accent/)
})
})

View File

@@ -3,6 +3,13 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
import Login from '../../app/pages/login.vue'
const wait = (ms = 200) => new Promise(r => setTimeout(r, ms))
const setupProtectedEndpoints = () => {
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
}
describe('auth middleware', () => {
it('allows /login without redirect when unauthenticated', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
@@ -11,28 +18,25 @@ describe('auth middleware', () => {
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
it.each([
[() => null, '/login', { redirect: '/' }],
[
() => {
throw createError({ statusCode: 401 })
},
'/login',
undefined,
],
])('redirects to /login when unauthenticated: %s', async (meResponse, expectedPath, expectedQuery) => {
registerEndpoint('/api/me', meResponse, { method: 'GET' })
setupProtectedEndpoints()
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 200))
await wait(meResponse.toString().includes('401') ? 250 : 200)
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/')
})
it('401 handler redirects to login when API returns 401', async () => {
registerEndpoint('/api/me', () => {
throw createError({ statusCode: 401 })
}, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 250))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.path).toBe(expectedPath)
if (expectedQuery) {
expect(router.currentRoute.value.query).toMatchObject(expectedQuery)
}
})
})

View File

@@ -1,46 +1,44 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import DefaultLayout from '../../app/layouts/default.vue'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
}
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
describe('default layout', () => {
it('renders KestrelOS header', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.text()).toContain('KestrelOS')
beforeEach(() => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
})
it('renders drawer toggle with accessible label on mobile', async () => {
withAuth()
it.each([
['KestrelOS header', 'KestrelOS'],
['drawer toggle', 'button[aria-label="Toggle navigation"]'],
])('renders %s', async (description, selector) => {
const wrapper = await mountSuspended(DefaultLayout)
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
expect(toggle.exists()).toBe(true)
if (selector.startsWith('button')) {
expect(wrapper.find(selector).exists()).toBe(true)
}
else {
expect(wrapper.text()).toContain(selector)
}
})
it('renders NavDrawer', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
})
it('renders user menu and sign out navigates home', async () => {
withAuth()
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
const wrapper = await mountSuspended(DefaultLayout)
await new Promise(r => setTimeout(r, 100))
await wait()
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
expect(menuTrigger.exists()).toBe(true)
await menuTrigger.trigger('click')
await new Promise(r => setTimeout(r, 50))
await wait(50)
const signOut = wrapper.find('button[role="menuitem"]')
expect(signOut.exists()).toBe(true)
expect(signOut.text()).toContain('Sign out')
await signOut.trigger('click')
await new Promise(r => setTimeout(r, 100))
await wait()
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/')

View File

@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
const wait = (ms = 150) => new Promise(r => setTimeout(r, ms))
describe('index page', () => {
it('renders map and uses cameras', async () => {
registerEndpoint('/api/cameras', () => ({
@@ -11,7 +13,7 @@ describe('index page', () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

102
test/nuxt/logger.spec.js Normal file
View File

@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { readBody } from 'h3'
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
const wait = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms))
describe('app/utils/logger', () => {
const consoleMocks = {}
const originalConsole = {}
const testState = {
serverCalls: [],
}
beforeEach(() => {
testState.serverCalls = []
const calls = { log: [], error: [], warn: [], debug: [] }
Object.keys(calls).forEach((key) => {
originalConsole[key] = console[key]
consoleMocks[key] = vi.fn((...args) => calls[key].push(args))
console[key] = consoleMocks[key]
})
registerEndpoint('/api/log', async (event) => {
const body = event.body || (await readBody(event).catch(() => ({})))
testState.serverCalls.push(body)
return { ok: true }
}, { method: 'POST' })
})
afterEach(() => {
Object.keys(originalConsole).forEach((key) => {
console[key] = originalConsole[key]
})
vi.restoreAllMocks()
})
describe('initLogger', () => {
it('sets sessionId and userId for server calls', async () => {
initLogger('session-123', 'user-456')
logError('Test message')
await wait()
expect(testState.serverCalls[0]).toMatchObject({
sessionId: 'session-123',
userId: 'user-456',
})
})
})
describe('log functions', () => {
it.each([
['logError', logError, 'error', 'error'],
['logWarn', logWarn, 'warn', 'warn'],
['logInfo', logInfo, 'info', 'log'],
['logDebug', logDebug, 'debug', 'log'],
])('%s logs to console and sends to server', async (name, logFn, level, consoleKey) => {
initLogger('session-123', 'user-456')
logFn('Test message', { key: 'value' })
await wait()
expect(consoleMocks[consoleKey]).toHaveBeenCalledWith(`[Test message]`, { key: 'value' })
expect(testState.serverCalls[0]).toMatchObject({
level,
message: 'Test message',
data: { key: 'value' },
})
})
it('handles server fetch failure gracefully', async () => {
registerEndpoint('/api/log', () => {
throw new Error('Network error')
}, { method: 'POST' })
initLogger('session-123', 'user-456')
expect(() => logError('Test error')).not.toThrow()
await wait()
expect(consoleMocks.error).toHaveBeenCalled()
})
})
describe('sendToServer', () => {
it('includes timestamp in server request', async () => {
initLogger('session-123', 'user-456')
logError('Test message')
await wait()
expect(testState.serverCalls[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
})
it('handles null sessionId and userId', async () => {
initLogger(null, null)
logError('Test message')
await wait()
const { sessionId, userId } = testState.serverCalls[0]
expect(sessionId === null || sessionId === undefined).toBe(true)
expect(userId === null || userId === undefined).toBe(true)
})
})
})

View File

@@ -2,31 +2,34 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Login from '../../app/pages/login.vue'
const wait = (ms = 50) => new Promise(r => setTimeout(r, ms))
describe('login page', () => {
it('renders sign in form (local auth always shown)', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 50))
await wait()
expect(wrapper.text()).toContain('Sign in')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('shows OIDC button when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
it.each([
[{ enabled: true, label: 'Sign in with Authentik' }, true, false],
[{ enabled: true, label: 'Sign in with OIDC' }, true, true],
])('shows OIDC when enabled: %j', async (oidcConfig, shouldShowButton, shouldShowPassword) => {
registerEndpoint('/api/auth/config', () => ({ oidc: oidcConfig }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Sign in with Authentik')
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
})
it('shows both OIDC button and password form when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with OIDC' } }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
await wait(150)
if (shouldShowButton) {
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
if (oidcConfig.label) {
expect(wrapper.text()).toContain(oidcConfig.label)
}
}
if (shouldShowPassword) {
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
}
})
})

View File

@@ -2,18 +2,41 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Members from '../../app/pages/members.vue'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const setupEndpoints = (userResponse) => {
registerEndpoint('/api/me', userResponse, { method: 'GET' })
registerEndpoint('/api/users', () => [])
}
describe('members page', () => {
it('renders Members heading', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
setupEndpoints(() => null)
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toContain('Members')
})
it('shows sign in message when no user', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
setupEndpoints(() => null)
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
it.each([
[
{ id: '1', identifier: 'admin', role: 'admin', avatar_url: null },
['Add user', /Only admins can change roles/],
],
[
{ id: '2', identifier: 'leader', role: 'leader', avatar_url: null },
['Members', 'Identifier'],
],
])('shows content for %s role', async (user, expectedTexts) => {
setupEndpoints(() => user)
const wrapper = await mountSuspended(Members)
await wait(user.role === 'leader' ? 150 : 100)
expectedTexts.forEach((text) => {
expect(wrapper.text()).toMatch(text)
})
})
})

View File

@@ -1,19 +1,18 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Poi from '../../app/pages/poi.vue'
describe('poi page', () => {
it('renders POI placement heading', async () => {
beforeEach(() => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toContain('POI placement')
})
it('shows view-only message when cannot edit', async () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
it.each([
['POI placement heading', 'POI placement'],
['view-only message', /View-only|Sign in as admin/],
])('renders %s', async (description, expected) => {
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
expect(wrapper.text()).toMatch(expected)
})
})

View File

@@ -2,27 +2,45 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const setupEndpoints = (camerasResponse) => {
registerEndpoint('/api/cameras', camerasResponse)
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
}
describe('useCameras', () => {
it('page uses cameras from API', async () => {
registerEndpoint('/api/cameras', () => ({
setupEndpoints(() => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
setupEndpoints(() => ({
devices: [],
liveSessions: [],
cotEntities,
}))
const wrapper = await mountSuspended(Index)
await wait()
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
setupEndpoints(() => {
throw new Error('network')
})
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
await wait(150)
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

View File

@@ -3,38 +3,59 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { defineComponent, h } from 'vue'
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const createTestComponent = (setupFn) => {
return defineComponent({
setup: setupFn,
})
}
const setupEndpoints = (liveResponse) => {
registerEndpoint('/api/live', liveResponse)
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
}
describe('useLiveSessions', () => {
it('fetches sessions from API and returns sessions ref', async () => {
registerEndpoint('/api/live', () => [
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
setupEndpoints(() => [{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 }])
const TestComponent = createTestComponent(() => {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 100))
await wait()
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
})
it('returns empty array when fetch fails', async () => {
registerEndpoint('/api/live', () => {
setupEndpoints(() => {
throw new Error('fetch failed')
})
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
const TestComponent = createTestComponent(() => {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 150))
const el = wrapper.find('[data-sessions]')
expect(el.exists()).toBe(true)
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
await wait(150)
const sessions = JSON.parse(wrapper.find('[data-sessions]').attributes('data-sessions'))
expect(sessions).toEqual([])
})
it('startPolling and stopPolling manage interval', async () => {
setupEndpoints(() => [])
const TestComponent = createTestComponent(() => {
const { startPolling, stopPolling } = useLiveSessions()
return () => h('div', {
onClick: () => {
startPolling()
startPolling()
stopPolling()
},
})
})
const wrapper = await mountSuspended(TestComponent)
await wrapper.trigger('click')
expect(wrapper.exists()).toBe(true)
})
})

104
test/unit/asyncLock.spec.js Normal file
View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { acquire, clearLocks } from '../../server/utils/asyncLock.js'
describe('asyncLock', () => {
beforeEach(() => {
clearLocks()
})
it('executes callback immediately when no lock exists', async () => {
const executed = { value: false }
await acquire('test', async () => {
executed.value = true
return 42
})
expect(executed.value).toBe(true)
})
it('returns callback result', async () => {
const result = await acquire('test', async () => {
return { value: 123 }
})
expect(result).toEqual({ value: 123 })
})
it('serializes concurrent operations on same key', async () => {
const results = []
const promises = Array.from({ length: 5 }, (_, i) =>
acquire('same-key', async () => {
results.push(`start-${i}`)
await new Promise(resolve => setTimeout(resolve, 10))
results.push(`end-${i}`)
return i
}),
)
await Promise.all(promises)
// Operations should be serialized: start-end pairs should not interleave
expect(results.length).toBe(10)
Array.from({ length: 5 }, (_, i) => {
expect(results[i * 2]).toBe(`start-${i}`)
expect(results[i * 2 + 1]).toBe(`end-${i}`)
})
})
it('allows parallel operations on different keys', async () => {
const results = []
const promises = Array.from({ length: 5 }, (_, i) =>
acquire(`key-${i}`, async () => {
results.push(`start-${i}`)
await new Promise(resolve => setTimeout(resolve, 10))
results.push(`end-${i}`)
return i
}),
)
await Promise.all(promises)
// Different keys can run in parallel
expect(results.length).toBe(10)
// All starts should come before all ends (parallel execution)
const starts = results.filter(r => r.startsWith('start'))
const ends = results.filter(r => r.startsWith('end'))
expect(starts.length).toBe(5)
expect(ends.length).toBe(5)
})
it('handles errors and releases lock', async () => {
const callCount = { value: 0 }
try {
await acquire('error-key', async () => {
callCount.value++
throw new Error('Test error')
})
}
catch (error) {
expect(error.message).toBe('Test error')
}
// Lock should be released, next operation should execute
await acquire('error-key', async () => {
callCount.value++
return 'success'
})
expect(callCount.value).toBe(2)
})
it('maintains lock ordering', async () => {
const order = []
const promises = Array.from({ length: 3 }, (_, i) =>
acquire('ordered', async () => {
order.push(`before-${i}`)
await new Promise(resolve => setTimeout(resolve, 5))
order.push(`after-${i}`)
}),
)
await Promise.all(promises)
// Should execute in order: before-0, after-0, before-1, after-1, before-2, after-2
expect(order).toEqual(['before-0', 'after-0', 'before-1', 'after-1', 'before-2', 'after-2'])
})
})

View File

@@ -1,43 +1,51 @@
import { describe, it, expect, afterEach } from 'vitest'
import { getAuthConfig } from '../../server/utils/authConfig.js'
import { describe, it, expect } from 'vitest'
import { getAuthConfig } from '../../server/utils/oidc.js'
import { withTemporaryEnv } from '../helpers/env.js'
describe('authConfig', () => {
const origEnv = { ...process.env }
afterEach(() => {
process.env = { ...origEnv }
it('returns oidc disabled when OIDC env vars are unset', () => {
withTemporaryEnv(
{ OIDC_ISSUER: undefined, OIDC_CLIENT_ID: undefined, OIDC_CLIENT_SECRET: undefined },
() => {
expect(getAuthConfig()).toEqual({ oidc: { enabled: false, label: '' } })
},
)
})
it('returns oidc disabled when OIDC env vars are unset', () => {
delete process.env.OIDC_ISSUER
delete process.env.OIDC_CLIENT_ID
delete process.env.OIDC_CLIENT_SECRET
expect(getAuthConfig()).toEqual({
oidc: { enabled: false, label: '' },
it.each([
[{ OIDC_ISSUER: 'https://auth.example.com' }, false],
[{ OIDC_CLIENT_ID: 'client' }, false],
[{ OIDC_ISSUER: 'https://auth.example.com', OIDC_CLIENT_ID: 'client' }, false],
])('returns oidc disabled when only some vars are set: %j', (env, expected) => {
withTemporaryEnv({ ...env, OIDC_CLIENT_SECRET: undefined }, () => {
expect(getAuthConfig().oidc.enabled).toBe(expected)
})
})
it('returns oidc disabled when only some OIDC vars are set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
delete process.env.OIDC_CLIENT_SECRET
expect(getAuthConfig().oidc.enabled).toBe(false)
})
it('returns oidc enabled and default label when all OIDC vars are set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
process.env.OIDC_CLIENT_SECRET = 'secret'
const config = getAuthConfig()
expect(config.oidc.enabled).toBe(true)
expect(config.oidc.label).toBe('Sign in with OIDC')
it('returns oidc enabled with default label when all vars are set', () => {
withTemporaryEnv(
{
OIDC_ISSUER: 'https://auth.example.com',
OIDC_CLIENT_ID: 'client',
OIDC_CLIENT_SECRET: 'secret',
},
() => {
expect(getAuthConfig()).toEqual({ oidc: { enabled: true, label: 'Sign in with OIDC' } })
},
)
})
it('uses OIDC_LABEL when set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
process.env.OIDC_CLIENT_SECRET = 'secret'
process.env.OIDC_LABEL = 'Sign in with Authentik'
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
withTemporaryEnv(
{
OIDC_ISSUER: 'https://auth.example.com',
OIDC_CLIENT_ID: 'client',
OIDC_CLIENT_SECRET: 'secret',
OIDC_LABEL: 'Sign in with Authentik',
},
() => {
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
},
)
})
})

View File

@@ -1,9 +1,7 @@
import { describe, it, expect } from 'vitest'
import { requireAuth } from '../../server/utils/authHelpers.js'
function mockEvent(user = null) {
return { context: { user } }
}
const mockEvent = (user = null) => ({ context: { user } })
describe('authHelpers', () => {
it('requireAuth throws 401 when no user', () => {
@@ -19,43 +17,29 @@ describe('authHelpers', () => {
it('requireAuth returns user when set', () => {
const user = { id: '1', identifier: 'a@b.com', role: 'member' }
expect(requireAuth(mockEvent(user))).toEqual(user)
})
it.each([
['member', 'adminOrLeader', 403],
['admin', 'adminOrLeader', null],
['leader', 'adminOrLeader', null],
['leader', 'admin', 403],
['admin', 'admin', null],
])('requireAuth with %s role and %s requirement', (userRole, requirement, expectedStatus) => {
const user = { id: '1', identifier: 'a', role: userRole }
const event = mockEvent(user)
expect(requireAuth(event)).toEqual(user)
})
it('requireAuth with adminOrLeader throws 403 for member', () => {
const event = mockEvent({ id: '1', identifier: 'a', role: 'member' })
expect(() => requireAuth(event, { role: 'adminOrLeader' })).toThrow()
try {
requireAuth(event, { role: 'adminOrLeader' })
if (expectedStatus === null) {
expect(requireAuth(event, { role: requirement })).toEqual(user)
}
catch (e) {
expect(e.statusCode).toBe(403)
else {
expect(() => requireAuth(event, { role: requirement })).toThrow()
try {
requireAuth(event, { role: requirement })
}
catch (e) {
expect(e.statusCode).toBe(expectedStatus)
}
}
})
it('requireAuth with adminOrLeader returns user for admin', () => {
const user = { id: '1', identifier: 'a', role: 'admin' }
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
})
it('requireAuth with adminOrLeader returns user for leader', () => {
const user = { id: '1', identifier: 'a', role: 'leader' }
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
})
it('requireAuth with admin throws 403 for leader', () => {
const event = mockEvent({ id: '1', identifier: 'a', role: 'leader' })
try {
requireAuth(event, { role: 'admin' })
}
catch (e) {
expect(e.statusCode).toBe(403)
}
})
it('requireAuth with admin returns user for admin', () => {
const user = { id: '1', identifier: 'a', role: 'admin' }
expect(requireAuth(mockEvent(user), { role: 'admin' })).toEqual(user)
})
})

View File

@@ -1,36 +1,40 @@
/**
* Ensures no API route that requires auth (requireAuth with optional role)
* is in the auth skip list. When adding a new protected API, add its path prefix to
* PROTECTED_PATH_PREFIXES in server/utils/authSkipPaths.js so these tests fail if it gets skipped.
* PROTECTED_PATH_PREFIXES in server/utils/authHelpers.js so these tests fail if it gets skipped.
*/
import { describe, it, expect } from 'vitest'
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authSkipPaths.js'
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authHelpers.js'
describe('authSkipPaths', () => {
it('does not skip any protected path (auth required for these)', () => {
for (const path of PROTECTED_PATH_PREFIXES) {
it('does not skip any protected path', () => {
const protectedPaths = [
...PROTECTED_PATH_PREFIXES,
'/api/cameras',
'/api/devices',
'/api/devices/any-id',
'/api/me',
'/api/pois',
'/api/pois/any-id',
'/api/users',
'/api/users/any-id',
]
protectedPaths.forEach((path) => {
expect(skipAuth(path)).toBe(false)
}
// Also check a concrete path under each prefix
expect(skipAuth('/api/cameras')).toBe(false)
expect(skipAuth('/api/devices')).toBe(false)
expect(skipAuth('/api/devices/any-id')).toBe(false)
expect(skipAuth('/api/me')).toBe(false)
expect(skipAuth('/api/pois')).toBe(false)
expect(skipAuth('/api/pois/any-id')).toBe(false)
expect(skipAuth('/api/users')).toBe(false)
expect(skipAuth('/api/users/any-id')).toBe(false)
})
})
it('skips known public paths', () => {
expect(skipAuth('/api/auth/login')).toBe(true)
expect(skipAuth('/api/auth/logout')).toBe(true)
expect(skipAuth('/api/auth/config')).toBe(true)
expect(skipAuth('/api/auth/oidc/authorize')).toBe(true)
expect(skipAuth('/api/auth/oidc/callback')).toBe(true)
expect(skipAuth('/api/health')).toBe(true)
expect(skipAuth('/api/health/ready')).toBe(true)
expect(skipAuth('/health')).toBe(true)
it.each([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
'/api/health',
'/api/health/ready',
'/health',
])('skips public path: %s', (path) => {
expect(skipAuth(path)).toBe(true)
})
it('keeps SKIP_PATHS and PROTECTED_PATH_PREFIXES disjoint', () => {

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_ENTITY_TTL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
WEBSOCKET_PATH,
MAX_PAYLOAD_BYTES,
MAX_STRING_LENGTH,
MAX_IDENTIFIER_LENGTH,
MEDIASOUP_RTC_MIN_PORT,
MEDIASOUP_RTC_MAX_PORT,
} from '../../server/utils/constants.js'
describe('constants', () => {
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
expect(WEBSOCKET_PATH).toBe('/ws')
expect(MAX_PAYLOAD_BYTES).toBe(64 * 1024)
expect(MAX_STRING_LENGTH).toBe(1000)
expect(MAX_IDENTIFIER_LENGTH).toBe(255)
expect(MEDIASOUP_RTC_MIN_PORT).toBe(40000)
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
})
it('handles invalid env var values gracefully', () => {
// Constants are evaluated at module load time, so env vars set in tests won't affect them
// This test verifies the pattern: Number(process.env.VAR) || default
const invalidValue = Number('invalid')
expect(Number.isNaN(invalidValue)).toBe(true)
expect(invalidValue || 15000).toBe(15000)
})
})

63
test/unit/cotAuth.spec.js Normal file
View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { hashPassword } from '../../server/utils/password.js'
import { validateCotAuth } from '../../server/utils/cotAuth.js'
describe('cotAuth', () => {
beforeEach(async () => {
setDbPathForTest(':memory:')
const { run } = await getDb()
const now = new Date().toISOString()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
['local-1', 'localuser', hashPassword('webpass'), 'member', now, 'local', null, null],
)
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['oidc-1', 'oidcuser', null, 'member', now, 'oidc', 'https://idp', 'sub-1', hashPassword('atakpass')],
)
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['oidc-2', 'nopass', null, 'member', now, 'oidc', 'https://idp', 'sub-2', null],
)
})
afterEach(() => {
setDbPathForTest(null)
})
it('validates local user with correct password', async () => {
const ok = await validateCotAuth('localuser', 'webpass')
expect(ok).toBe(true)
})
it('rejects local user with wrong password', async () => {
const ok = await validateCotAuth('localuser', 'wrong')
expect(ok).toBe(false)
})
it('validates OIDC user with correct ATAK password', async () => {
const ok = await validateCotAuth('oidcuser', 'atakpass')
expect(ok).toBe(true)
})
it('rejects OIDC user with wrong ATAK password', async () => {
const ok = await validateCotAuth('oidcuser', 'wrong')
expect(ok).toBe(false)
})
it('rejects OIDC user who has not set ATAK password', async () => {
const ok = await validateCotAuth('nopass', 'any')
expect(ok).toBe(false)
})
it('rejects unknown identifier', async () => {
const ok = await validateCotAuth('nobody', 'x')
expect(ok).toBe(false)
})
it('rejects empty identifier', async () => {
const ok = await validateCotAuth('', 'x')
expect(ok).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More