40 Commits

Author SHA1 Message Date
CI cebeee4040 release v1.1.6 [skip ci] 2026-06-24 20:55:04 +00:00
keligrubb bb01e9a06c Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
2026-06-24 20:54:50 +00:00
CI a6b87305a1 release v1.1.5 [skip ci] 2026-06-21 04:26:35 +00:00
renovate-bot 829db93065 chore(deps): update https://git.keligrubb.com/actions/checkout action to v7 (#35)
Push / release (push) Successful in 14s
Push / publish (push) Failing after 3h2m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>

### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)

- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)

### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)

- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)

</details>

---

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

Reviewed-on: #35
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-06-21 04:26:21 +00:00
renovate-bot afaf305bda chore(deps): update all non-major dependencies (#32)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.8` → `2.4.10`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.8/2.4.10) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.10?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.8/2.4.10?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.21` → `3.19.22`](https://renovatebot.com/diffs/npm/mediasoup/3.19.21/3.19.22) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.22?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.21/3.19.22?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.19.0` → `3.20.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.19.0/3.20.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.19.0/3.20.0?slim=true) |
| [nuxt](https://nuxt.com) ([source](https://github.com/nuxt/nuxt/tree/HEAD/packages/nuxt)) | [`4.4.2` → `4.4.4`](https://renovatebot.com/diffs/npm/nuxt/4.4.2/4.4.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/nuxt/4.4.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nuxt/4.4.2/4.4.4?slim=true) |
| [openid-client](https://github.com/panva/openid-client) | [`6.8.3` → `6.8.4`](https://renovatebot.com/diffs/npm/openid-client/6.8.3/6.8.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/openid-client/6.8.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/openid-client/6.8.3/6.8.4?slim=true) |

---

### Release Notes

<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>

### [`v2.4.10`](https://github.com/vuejs/test-utils/compare/v2.4.9...v2.4.10)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.9...v2.4.10)

### [`v2.4.9`](https://github.com/vuejs/test-utils/releases/tag/v2.4.9)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.8...v2.4.9)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.8...v2.4.9)

##### 🩹 Fixes

- Tolerate duplicate attachTo cleanup ([#&#8203;2830](https://github.com/vuejs/test-utils/pull/2830))

##### 📖 Documentation

- Document release process ([#&#8203;2834](https://github.com/vuejs/test-utils/pull/2834))

##### 🏡 Chore

- Migrate renovate config ([5d37934](https://github.com/vuejs/test-utils/commit/5d37934))

##### 🤖 CI

- Pin github actions to commit hashes ([75dcef3](https://github.com/vuejs/test-utils/commit/75dcef3))

##### ❤️ Contributors

- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.22`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31922)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.21...3.19.22)

- Node: Avoid "worker died" event when the Node application is closed via signal without calling `worker.close()` ([PR #&#8203;1788](https://github.com/versatica/mediasoup/pull/1788)).

</details>

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.20.0`](https://github.com/versatica/mediasoup-client/compare/3.19.0...3.20.0)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.19.0...3.20.0)

</details>

<details>
<summary>nuxt/nuxt (nuxt)</summary>

### [`v4.4.4`](https://github.com/nuxt/nuxt/compare/v4.4.2...v4.4.4)

[Compare Source](https://github.com/nuxt/nuxt/compare/v4.4.2...v4.4.4)

</details>

<details>
<summary>panva/openid-client (openid-client)</summary>

### [`v6.8.4`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#684-2026-04-27)

[Compare Source](https://github.com/panva/openid-client/compare/v6.8.3...v6.8.4)

##### Fixes

- apply optional non-repudiation on generic grant ID Tokens ([6202888](https://github.com/panva/openid-client/commit/62028884943a1987eb1ddedc9efc01953189a14f))
- filter jwe decryption keys by algorithm ([34e2ffd](https://github.com/panva/openid-client/commit/34e2ffdea5b3b5afffeff55208aaad1d3b42d0fb))
- preserve poll abort signals on requests ([96a2d17](https://github.com/panva/openid-client/commit/96a2d176b982196f432990ef1f64978cb7cb881c))
- retry dpop nonce errors for generic grants ([498c4d9](https://github.com/panva/openid-client/commit/498c4d9c098340ad2f8b9dbb91bfe7f1166a3fd4))

</details>

---

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

Reviewed-on: #32
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-05-04 15:05:24 +00:00
CI 2a48715ea8 release v1.1.4 [skip ci] 2026-04-29 20:24:17 +00:00
renovate-bot 9d34f5f7c5 chore(deps): update all non-major dependencies (#31)
Push / release (push) Successful in 30s
Push / publish (push) Failing after 3h9m29s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.6/2.4.8?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.7.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.6.0/5.7.2?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.19/3.19.21?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.8/3.19.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.32/3.5.33?slim=true) |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.4/5.0.6?slim=true) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

#####    🚀 Experimental Features

- **coverage**: Istanbul to support `instrumenter` option  -  by [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)

#####    🐞 Bug Fixes

- \--project negation excludes browser instances  -  by [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
- Project color label on html reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
- Fix `vi.defineHelper` called as object method  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
- Alias `agent` reporter to `minimal`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
- Respect diff config options in soft assertions  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
- Respect diff config options in soft assertions "  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
- **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
- **snapshot**: Increase default snapshot max output length  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
- **ui**: Fix jsx/tsx syntax highlight  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
- **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
- **api**: Make test-specification options writable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

</details>

<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>

### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)

##### 🩹 Fixes

- Correct declaration entrypoint ([#&#8203;2826](https://github.com/vuejs/test-utils/pull/2826))

##### 🤖 CI

- Enable pkg.pr.new ([#&#8203;2827](https://github.com/vuejs/test-utils/pull/2827))

##### ❤️ Contributors

- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)

##### 🚀 Enhancements

- Add Chinese docs translation ([#&#8203;2552](https://github.com/vuejs/test-utils/pull/2552))
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#&#8203;2655](https://github.com/vuejs/test-utils/pull/2655))

##### 🩹 Fixes

- Preserve code from keyboard events ([#&#8203;2434](https://github.com/vuejs/test-utils/pull/2434))
- Switch browser and require exports definitions ([#&#8203;2501](https://github.com/vuejs/test-utils/pull/2501))
- Re-add peer dependencies but with wider range ([#&#8203;2511](https://github.com/vuejs/test-utils/pull/2511))
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
- Resolve TypeScript type errors in .vitepress/config ([#&#8203;2549](https://github.com/vuejs/test-utils/pull/2549))
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
- Text() misses content for array functional component ([#&#8203;2579](https://github.com/vuejs/test-utils/pull/2579))
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
- **deps:** Update dependency vue-component-type-helpers to v3 ([#&#8203;2683](https://github.com/vuejs/test-utils/pull/2683))
- Remove wrapper div when unmount ([#&#8203;2700](https://github.com/vuejs/test-utils/pull/2700))
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#&#8203;2713](https://github.com/vuejs/test-utils/pull/2713))
- Add missing peerDependency [@&#8203;vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))

##### 💅 Refactors

- Enforce consistent usage of type imports ([#&#8203;2734](https://github.com/vuejs/test-utils/pull/2734))

##### 📖 Documentation

- Clarify findComponent vs getComponent ([#&#8203;2435](https://github.com/vuejs/test-utils/pull/2435))
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
- Add note about partial transition stub support ([#&#8203;2431](https://github.com/vuejs/test-utils/pull/2431))
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
- Fix plugin TS declaration example ([#&#8203;2466](https://github.com/vuejs/test-utils/pull/2466))
- Fixed incorrect checkbox value check ([#&#8203;2495](https://github.com/vuejs/test-utils/pull/2495))
- Capital letter in sentence fix ([#&#8203;2499](https://github.com/vuejs/test-utils/pull/2499))
- Import missing DOMWrapper on Implementation of the plugin section ([#&#8203;2519](https://github.com/vuejs/test-utils/pull/2519))
- Add migration step for deprecated ref syntax in findAllComponents ([#&#8203;2498](https://github.com/vuejs/test-utils/pull/2498))
- Correct anchor hash links and fix typo ([#&#8203;2551](https://github.com/vuejs/test-utils/pull/2551))
- Center logo on home ([#&#8203;2559](https://github.com/vuejs/test-utils/pull/2559))
- **zh-cn:** Review a-crash-course ([#&#8203;2563](https://github.com/vuejs/test-utils/pull/2563))
- Use code-group for install commands ([#&#8203;2571](https://github.com/vuejs/test-utils/pull/2571))
- **zh-cn:** Review event-handing.md ([#&#8203;2572](https://github.com/vuejs/test-utils/pull/2572))
- **zh-cn:** Enhance conditional-rendering.md ([#&#8203;2562](https://github.com/vuejs/test-utils/pull/2562))
- **zh-cn:** Review easy-to-test ([#&#8203;2567](https://github.com/vuejs/test-utils/pull/2567))
- **zh-cn:** Review passing-data.md ([#&#8203;2575](https://github.com/vuejs/test-utils/pull/2575))
- **zh-cn:** Review async-suspense.md ([#&#8203;2576](https://github.com/vuejs/test-utils/pull/2576))
- **zh:** 优化 API 文档格式和内容 ([#&#8203;2569](https://github.com/vuejs/test-utils/pull/2569))
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#&#8203;2578](https://github.com/vuejs/test-utils/pull/2578))
- **zh-cn:** Review http-requests.md ([#&#8203;2580](https://github.com/vuejs/test-utils/pull/2580))
- **zh-cn:** Review forms ([#&#8203;2582](https://github.com/vuejs/test-utils/pull/2582))
- **zh-cn:** Guide/advanced/slots.md ([#&#8203;2565](https://github.com/vuejs/test-utils/pull/2565))
- **zh:** Review extending-vtu ([#&#8203;2583](https://github.com/vuejs/test-utils/pull/2583))
- **zh:** Review index ([#&#8203;2584](https://github.com/vuejs/test-utils/pull/2584))
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
- **zh:** Review transitions, component-instance, and reusability-composition ([#&#8203;2616](https://github.com/vuejs/test-utils/pull/2616))
- **zh:** Review v-model and vuex ([#&#8203;2617](https://github.com/vuejs/test-utils/pull/2617))
- **zh:** Review all the rest advanced guide ([#&#8203;2619](https://github.com/vuejs/test-utils/pull/2619))
- **zh:** Review migration ([#&#8203;2623](https://github.com/vuejs/test-utils/pull/2623))
- Fix a typo in transitions.md ([#&#8203;2635](https://github.com/vuejs/test-utils/pull/2635))
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
- Update Essentials section to setup (composition api) ([#&#8203;2647](https://github.com/vuejs/test-utils/pull/2647))
- Typos in examples ([#&#8203;2678](https://github.com/vuejs/test-utils/pull/2678))
- Typo in easy-to-test.md ([#&#8203;2710](https://github.com/vuejs/test-utils/pull/2710))
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
- Updated example TodoApp to script setup ([#&#8203;2727](https://github.com/vuejs/test-utils/pull/2727))
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#&#8203;2743](https://github.com/vuejs/test-utils/pull/2743))
- Follow-up fixes for the conditional rendering guide ([#&#8203;2744](https://github.com/vuejs/test-utils/pull/2744))
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#&#8203;2799](https://github.com/vuejs/test-utils/pull/2799))
- Restore Options API component for data() mounting example ([#&#8203;2804](https://github.com/vuejs/test-utils/pull/2804))
- Promote Vitest as recommended test runner ([#&#8203;2805](https://github.com/vuejs/test-utils/pull/2805))
- **api:** Note that setValue does not accept objects on `<select>` ([#&#8203;2819](https://github.com/vuejs/test-utils/pull/2819))

##### 🏡 Chore

- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
- Expose Stubs type publicly ([#&#8203;2492](https://github.com/vuejs/test-utils/pull/2492))
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
- Use github issue forms ([#&#8203;2673](https://github.com/vuejs/test-utils/pull/2673))
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))

##### 🤖 CI

- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
- Add trusted publishing release workflow ([#&#8203;2825](https://github.com/vuejs/test-utils/pull/2825))

##### ❤️ Contributors

- Lachlan Miller ([@&#8203;lmiller1990](https://github.com/lmiller1990))
- cexbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Nicolas Bonamy ([@&#8203;nbonamy](https://github.com/nbonamy))
- KatWorkGit ([@&#8203;KatWorkGit](https://github.com/KatWorkGit))
- Wouter Kroes ([@&#8203;wouterkroes](https://github.com/wouterkroes))
- Rama Muhammad Murshal ([@&#8203;ramammurshal](https://github.com/ramammurshal))
- Evan You ([@&#8203;yyx990803](https://github.com/yyx990803))
- Vlad Starkovsky ([@&#8203;starkovsky](https://github.com/starkovsky))
- Joe ([@&#8203;joaoprp](https://github.com/joaoprp))
- Priyadarshi Kumar ([@&#8203;Psingh132](https://github.com/Psingh132))
- Sébastien Ronveaux ([@&#8203;sronveaux](https://github.com/sronveaux))
- Gilliam ([@&#8203;Gi11i4m](https://github.com/Gi11i4m))
- Baranov Dmytro ([@&#8203;dimas7001](https://github.com/dimas7001))
- BrendonHenrique ([@&#8203;BrendonHenrique](https://github.com/BrendonHenrique))
- Lorenz van Herwaarden ([@&#8203;lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
- wuzhiqing ([@&#8203;DDDDD12138](https://github.com/DDDDD12138))
- 阿菜 Cai ([@&#8203;RSS1102](https://github.com/RSS1102))
- Jinjiang ([@&#8203;Jinjiang](https://github.com/Jinjiang))
- Kylin ([@&#8203;lxKylin](https://github.com/lxKylin))
- Qianhe Chen ([@&#8203;chenqianhe](https://github.com/chenqianhe))
- 时瑶 ([@&#8203;KiritaniAyaka](https://github.com/KiritaniAyaka))
- h7ml ([@&#8203;h7ml](https://github.com/h7ml))
- Nicander ([@&#8203;Nicander93](https://github.com/Nicander93))
- Take-John ([@&#8203;takejohn](https://github.com/takejohn))
- ilyasherstoboev ([@&#8203;ilyasherstoboev](https://github.com/ilyasherstoboev))
- aimerie ([@&#8203;aimerie](https://github.com/aimerie))
- Miguel Rincon ([@&#8203;miguelrincon](https://github.com/miguelrincon))
- bcastlel ([@&#8203;bcastlel](https://github.com/bcastlel))
- Claudiu ([@&#8203;sofuxro](https://github.com/sofuxro))
- Artem Dragunov ([@&#8203;dragunovartem99](https://github.com/dragunovartem99))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Koen Mertens ([@&#8203;KCMertens](https://github.com/KCMertens))
- meomking ([@&#8203;CaptainWang98](https://github.com/CaptainWang98))
- Pepijn Olivier ([@&#8203;pepijnolivier](https://github.com/pepijnolivier))
- Tomina ([@&#8203;Thomaash](https://github.com/Thomaash))
- Gareth Jones ([@&#8203;G-Rath](https://github.com/G-Rath))
- Jerry Hogan ([@&#8203;hdJerry](https://github.com/hdJerry))
- Marco Pasqualetti ([@&#8203;marcalexiei](https://github.com/marcalexiei))
- guoxk ([@&#8203;guoxk-me](https://github.com/guoxk-me))
- kimulaco ([@&#8203;kimulaco](https://github.com/kimulaco))
- Erwan IQUEL ([@&#8203;Olympus5](https://github.com/Olympus5))
- Matt Van Horn ([@&#8203;mvanhorn](https://github.com/mvanhorn))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

</details>

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

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

[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

#### Bug Fixes

- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#&#8203;20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])

#### Documentation

- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#&#8203;20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh  S M)
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs:  Enable Eleventy incremental mode for watch ([#&#8203;20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh  S M)
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#&#8203;20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#&#8203;20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh  S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)

#### Chores

- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#&#8203;20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#&#8203;20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#&#8203;20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test  ([#&#8203;20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#&#8203;20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#&#8203;20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)

</details>

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #&#8203;705, #&#8203;817

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)

- allow numerical external entity for backward compatibility
- fix [#&#8203;705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
- fix [#&#8203;817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long

### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @&#8203;nodable/entities and FXB

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)

- Use `@nodable/entities` v2.1.0
  - breaking changes
    - single entity scan. You're not allowed to use entity value to form another entity name.
    - you cant add numeric external entity
    - entity error message when expantion limit is crossed might change
  - typings are updated for new options related to process entity
  - please follow documentation of `@nodable/entities` for more detail.
  - performance
    - if processEntities is false, then there should not be impact on performance.
    - if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
    - if processEntities is true, and you pass entity decoder separately
      - if no entity then performance should be same as before
      - if there are entities then performance should be increased from past versions
  - ignoreAttributes is not required to be set to set xml version for NCR entity value
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content

### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)

- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #&#8203;1780](https://github.com/versatica/mediasoup/pull/1780)).

### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)

- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #&#8203;1777](https://github.com/versatica/mediasoup/pull/1777)).

</details>

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)

</details>

<details>
<summary>vuejs/core (vue)</summary>

### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)

##### Bug Fixes

- **compiler-sfc:** handle nested :deep in selector pseudos ([#&#8203;14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#&#8203;14724](https://github.com/vuejs/core/issues/14724)
- **reactivity:** unlink effect scopes on out-of-order off ([#&#8203;14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#&#8203;14733](https://github.com/vuejs/core/issues/14733)
- **runtime-dom:** preserve textarea resize dimensions ([#&#8203;14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#&#8203;14741](https://github.com/vuejs/core/issues/14741)
- **teleport:** don't move teleport children if not mounted ([#&#8203;14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#&#8203;14701](https://github.com/vuejs/core/issues/14701)
- **transition:** preserve placeholder for conditional explicit default slots ([#&#8203;14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#&#8203;14727](https://github.com/vuejs/core/issues/14727)

</details>

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)

#####    🐞 Bug Fixes

- Missing closing quote in generated import  -  by [@&#8203;zjy040525](https://github.com/zjy040525) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)

### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)

#####    🚀 Features

- Enable standard schema param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
- Normalize param parsers once  -  by [@&#8203;posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)

#####    🐞 Bug Fixes

- Track definePage imports per-file to fix named view race condition  -  by [@&#8203;posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
- Avoid double decoding hash on string location  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)

</details>

---

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

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/31
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-29 20:23:47 +00:00
CI 5b4bb6b33a release v1.1.3 [skip ci] 2026-04-19 02:28:28 +00:00
renovate-bot e16b492257 chore(deps): update dependency mediasoup-client to v3.18.8 (#30)
Push / release (push) Successful in 15s
Push / publish (push) Successful in 1m50s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.18.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.7/3.18.8?slim=true) |

---

### Release Notes

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)

</details>

---

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

Reviewed-on: #30
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-19 02:28:13 +00:00
CI 265e02119a release v1.1.2 [skip ci] 2026-04-15 04:10:14 +00:00
renovate-bot 82d454ade4 Update dependency fast-xml-parser to v5.6.0 (#28)
Push / release (push) Successful in 23s
Push / publish (push) Successful in 1m8s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.12/5.6.0?slim=true) |

---

### Release Notes

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @&#8203;nodable/entities to replace entities

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)

- No API change
- No change in performance for basic usage
- No typing change
- No config change
- new dependency
- breaking: error messages for entities might have been changed.
-

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- 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:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #28
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 04:09:54 +00:00
CI a9b300d711 release v1.1.1 [skip ci] 2026-04-15 03:03:37 +00:00
keligrubb fded3a04d4 ci: split push release/publish and harden workflows (#27)
Push / release (push) Successful in 47s
Push / publish (push) Successful in 1m0s
### Added
* Separate release from Docker/Helm publish
* enrich releases with PRbodies when available
* tighten release.sh validation and idempotency
* trim PR docker-build metadata for act-runner stability

Reviewed-on: #27
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 03:03:04 +00:00
CI 0d897f17b5 release v1.1.0 [skip ci] 2026-04-15 02:45:02 +00:00
renovate-bot 216f6f83fe Update all non-major dependencies (#25)
Push / release-docker-helm (push) Failing after 3m26s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [@iconify-json/tabler](https://icon-sets.iconify.design/tabler/) | [`1.2.31` → `1.2.33`](https://renovatebot.com/diffs/npm/@iconify-json%2ftabler/1.2.31/1.2.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@iconify-json%2ftabler/1.2.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@iconify-json%2ftabler/1.2.31/1.2.33?slim=true) | devDependencies | patch |
| [@nuxt/test-utils](https://github.com/nuxt/test-utils) | [`4.0.0` → `4.0.2`](https://renovatebot.com/diffs/npm/@nuxt%2ftest-utils/4.0.0/4.0.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@nuxt%2ftest-utils/4.0.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nuxt%2ftest-utils/4.0.0/4.0.2?slim=true) | devDependencies | patch |
| [@playwright/test](https://playwright.dev) ([source](https://github.com/microsoft/playwright)) | [`1.58.2` → `1.59.1`](https://renovatebot.com/diffs/npm/@playwright%2ftest/1.58.2/1.59.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@playwright%2ftest/1.59.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@playwright%2ftest/1.58.2/1.59.1?slim=true) | devDependencies | minor |
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.0` → `4.1.4`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.0/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.0/4.1.4?slim=true) | devDependencies | patch |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.0.3` → `10.2.0`](https://renovatebot.com/diffs/npm/eslint/10.0.3/10.2.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.0.3/10.2.0?slim=true) | devDependencies | minor |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.3` → `5.5.12`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.3/5.5.12) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.5.12?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.3/5.5.12?slim=true) | dependencies | patch |
| [happy-dom](https://github.com/capricorn86/happy-dom) | [`20.8.3` → `20.9.0`](https://renovatebot.com/diffs/npm/happy-dom/20.8.3/20.9.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/happy-dom/20.9.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/happy-dom/20.8.3/20.9.0?slim=true) | devDependencies | minor |
| [hls.js](https://github.com/video-dev/hls.js) | [`1.6.15` → `1.6.16`](https://renovatebot.com/diffs/npm/hls.js/1.6.15/1.6.16) | ![age](https://developer.mend.io/api/mc/badges/age/npm/hls.js/1.6.16?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/hls.js/1.6.15/1.6.16?slim=true) | dependencies | patch |
| mcr.microsoft.com/playwright | `v1.58.2-noble` → `v1.59.1-noble` | ![age](https://developer.mend.io/api/mc/badges/age/docker/mcr.microsoft.com%2fplaywright/v1.59.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/mcr.microsoft.com%2fplaywright/v1.58.2/v1.59.1?slim=true) | container | minor |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.17` → `3.19.19`](https://renovatebot.com/diffs/npm/mediasoup/3.19.17/3.19.19) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.19?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.17/3.19.19?slim=true) | dependencies | patch |
| [openid-client](https://github.com/panva/openid-client) | [`6.8.2` → `6.8.3`](https://renovatebot.com/diffs/npm/openid-client/6.8.2/6.8.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/openid-client/6.8.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/openid-client/6.8.2/6.8.3?slim=true) | dependencies | patch |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.0` → `4.1.4`](https://renovatebot.com/diffs/npm/vitest/4.1.0/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.0/4.1.4?slim=true) | devDependencies | patch |
| [vue](https://github.com/vuejs/core/tree/main/packages/vue#readme) ([source](https://github.com/vuejs/core)) | [`3.5.30` → `3.5.32`](https://renovatebot.com/diffs/npm/vue/3.5.30/3.5.32) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.32?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.30/3.5.32?slim=true) | dependencies | patch |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.3` → `5.0.4`](https://renovatebot.com/diffs/npm/vue-router/5.0.3/5.0.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.3/5.0.4?slim=true) | dependencies | patch |
| [ws](https://github.com/websockets/ws) | [`8.19.0` → `8.20.0`](https://renovatebot.com/diffs/npm/ws/8.19.0/8.20.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/ws/8.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ws/8.19.0/8.20.0?slim=true) | dependencies | minor |

---

### Release Notes

<details>
<summary>nuxt/test-utils (@&#8203;nuxt/test-utils)</summary>

### [`v4.0.2`](https://github.com/nuxt/test-utils/releases/tag/v4.0.2)

[Compare Source](https://github.com/nuxt/test-utils/compare/v4.0.1...v4.0.2)

#### 👉 Changelog

[compare changes](https://github.com/nuxt/test-utils/compare/v4.0.1...v4.0.2)

##### 🩹 Fixes

- **config:** Respect override dev value ([#&#8203;1602](https://github.com/nuxt/test-utils/pull/1602))

##### 🤖 CI

- Use pnpm publish to resolve workspace dependencies ([#&#8203;1651](https://github.com/nuxt/test-utils/pull/1651))

##### ❤️ Contributors

- Julien Huang ([@&#8203;huang-julien](https://github.com/huang-julien))
- Vasily Kuzin ([@&#8203;ExEr7um](https://github.com/ExEr7um))

### [`v4.0.1`](https://github.com/nuxt/test-utils/releases/tag/v4.0.1)

[Compare Source](https://github.com/nuxt/test-utils/compare/v4.0.0...v4.0.1)

#### 👉 Changelog

[compare changes](https://github.com/nuxt/test-utils/compare/v4.0.0...v4.0.1)

##### 🩹 Fixes

- **config:** Rename deps.optimizer.web to client for vitest4 ([#&#8203;1593](https://github.com/nuxt/test-utils/pull/1593))
- **runtime-utils:** Fix `mockNuxtImport` types when using string target ([#&#8203;1592](https://github.com/nuxt/test-utils/pull/1592))
- **config:** Pass non-project options for non-nuxt simple setup ([#&#8203;1582](https://github.com/nuxt/test-utils/pull/1582))
- **config:** Do not import `defineConfig` from `vite` ([1aa5e8748](https://github.com/nuxt/test-utils/commit/1aa5e8748))
- **runtime:** Handle `ResourceLoader` removal in jsdom v28 ([#&#8203;1611](https://github.com/nuxt/test-utils/pull/1611))
- **config,vitest-environment:** Directly import peerDeps ([#&#8203;1617](https://github.com/nuxt/test-utils/pull/1617))
- **runtime-utils:** Align mount options merge w/ vue-test-utils ([#&#8203;1610](https://github.com/nuxt/test-utils/pull/1610))
- **vitest-environment:** Avoid `vitest/environments` import warning ([#&#8203;1627](https://github.com/nuxt/test-utils/pull/1627))
- **runtime:** Avoid error when vue/test-utils is not installed ([#&#8203;1646](https://github.com/nuxt/test-utils/pull/1646))
- **config:** Prefer project h3 version if present ([#&#8203;1641](https://github.com/nuxt/test-utils/pull/1641))

##### 🏡 Chore

- Bump `vitest-environment-nuxt` versions ([f5ec72127](https://github.com/nuxt/test-utils/commit/f5ec72127))
- Use workspace dependency ([14fb254a7](https://github.com/nuxt/test-utils/commit/14fb254a7))
- Example playwright config improve type annotation for devices ([#&#8203;1581](https://github.com/nuxt/test-utils/pull/1581))
- `pkg-pr-new` prerelease `vitest-environment-nuxt` ([#&#8203;1601](https://github.com/nuxt/test-utils/pull/1601))
- Allow explicit any ([633c93c2a](https://github.com/nuxt/test-utils/commit/633c93c2a))
- Switch unit test target to dir and move type unit tests to test:types ([#&#8203;1618](https://github.com/nuxt/test-utils/pull/1618))
- Update lockfile ([8306abf00](https://github.com/nuxt/test-utils/commit/8306abf00))

#####  Tests

- Add failing test for stubbed global provide ([#&#8203;1314](https://github.com/nuxt/test-utils/pull/1314))
- Update assertions deprecated in vitest 4.1 ([#&#8203;1629](https://github.com/nuxt/test-utils/pull/1629))
- Change example/workspace to use glob based projects setup ([#&#8203;1585](https://github.com/nuxt/test-utils/pull/1585))

##### 🤖 CI

- Pin github actions to full-length commit shas ([2832fd6d5](https://github.com/nuxt/test-utils/commit/2832fd6d5))
- Avoid checkout for reproduction comment ([e4e67ab09](https://github.com/nuxt/test-utils/commit/e4e67ab09))
- Rename workflow ([99318b9fc](https://github.com/nuxt/test-utils/commit/99318b9fc))
- Correctly publish `pkg-pr-new` prerelease ([#&#8203;1598](https://github.com/nuxt/test-utils/pull/1598))

##### ❤️ Contributors

- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
- Yoshihiro Yamaguchi ([@&#8203;yamachi4416](https://github.com/yamachi4416))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Paul Melero ([@&#8203;paulmelero](https://github.com/paulmelero))

</details>

<details>
<summary>microsoft/playwright (@&#8203;playwright/test)</summary>

### [`v1.59.1`](https://github.com/microsoft/playwright/releases/tag/v1.59.1)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.59.0...v1.59.1)

##### Bug Fixes

- **\[Windows]** Reverted hiding console window when spawning browser processes, which caused regressions including broken `codegen`, `--ui` and `show` commands ([#&#8203;39990](https://github.com/microsoft/playwright/issues/39990))

### [`v1.59.0`](https://github.com/microsoft/playwright/releases/tag/v1.59.0)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.58.2...v1.59.0)

#### 🎬 Screencast

New [page.screencast](https://playwright.dev/docs/api/class-page#page-screencast) API provides a unified interface for capturing page content with:

- Screencast recordings
- Action annotations
- Visual overlays
- Real-time frame capture
- Agentic video receipts

<center>

<img src="https://raw.githubusercontent.com/microsoft/playwright/main/docs/src/images/release-notes-1.59-screencast-demo.gif" alt="Demo" width="500" height="313" />

</center>

**Screencast recording** — record video with precise start/stop control, as an alternative to the [`recordVideo`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-video) option:

```js
await page.screencast.start({ path: 'video.webm' });
// ... perform actions ...
await page.screencast.stop();
```

**Action annotations** — enable built-in visual annotations that highlight interacted elements and display action titles during recording:

```js
await page.screencast.showActions({ position: 'top-right' });
```

[screencast.showActions()](https://playwright.dev/docs/api/class-screencast#screencast-show-actions) accepts `position` (`'top-left'`, `'top'`, `'top-right'`, `'bottom-left'`, `'bottom'`, `'bottom-right'`), `duration` (ms per annotation), and `fontSize` (px). Returns a disposable to stop showing actions.

Action annotations can also be enabled in test fixtures via the `video` option:

```js
// playwright.config.ts
export default defineConfig({
  use: {
    video: {
      mode: 'on',
      show: {
        actions: { position: 'top-left' },
        test: { position: 'top-right' },
      },
    },
  },
});
```

**Visual overlays** — add chapter titles and custom HTML overlays on top of the page for richer narration:

```js
await page.screencast.showChapter('Adding TODOs', {
  description: 'Type and press enter for each TODO',
  duration: 1000,
});

await page.screencast.showOverlay('<div style="color: red">Recording</div>');
```

**Real-time frame capture** — stream JPEG-encoded frames for custom processing like thumbnails, live previews, AI vision, and more:

```js
await page.screencast.start({
  onFrame: ({ data }) => sendToVisionModel(data),
  size: { width: 800, height: 600 },
});
```

**Agentic video receipts** — coding agents can produce video evidence of their work. After completing a task, an agent can record a walkthrough video with rich annotations for human review:

```js
await page.screencast.start({ path: 'receipt.webm' });
await page.screencast.showActions({ position: 'top-right' });

await page.screencast.showChapter('Verifying checkout flow', {
  description: 'Added coupon code support per ticket #&#8203;1234',
});

// Agent performs the verification steps...
await page.locator('#coupon').fill('SAVE20');
await page.locator('#apply-coupon').click();
await expect(page.locator('.discount')).toContainText('20%');

await page.screencast.showChapter('Done', {
  description: 'Coupon applied, discount reflected in total',
});

await page.screencast.stop();
```

The resulting video serves as a receipt: chapter titles provide context, action annotations highlight each interaction, and the visual walkthrough is faster to review than text logs.

#### 🔗 Interoperability

New [browser.bind()](https://playwright.dev/docs/api/class-browser#browser-bind) API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to.

**Bind a browser** — start a browser and bind it so others can connect:

```js
const { endpoint } = await browser.bind('my-session', {
  workspaceDir: '/my/project',
});
```

**Connect from playwright-cli** — connect to the running browser from your favorite coding agent.

```bash
playwright-cli attach my-session
playwright-cli -s my-session snapshot
```

**Connect from [@&#8203;playwright/mcp](https://github.com/playwright/mcp)** — or point your MCP server to the running browser.

```bash
@&#8203;playwright/mcp --endpoint=my-session
```

**Connect from a Playwright client** — use API to connect to the browser. Multiple clients at a time are supported!

```js
const browser = await chromium.connect(endpoint);
```

Pass `host` and `port` options to bind over WebSocket instead of a named pipe:

```js
const { endpoint } = await browser.bind('my-session', {
  host: 'localhost',
  port: 0,
});
// endpoint is a ws:// URL
```

Call [browser.unbind()](https://playwright.dev/docs/api/class-browser#browser-unbind) to stop accepting new connections.

#### 📊 Observability

Run `playwright-cli show` to open the Dashboard that lists all the bound browsers, their statuses, and allows interacting with them:

- See what your agent is doing on the background browsers
- Click into the sessions for manual interventions
- Open DevTools to inspect pages from the background browsers.

<center>

<img src="https://raw.githubusercontent.com/microsoft/playwright/main/docs/src/images/release-notes-1.59-dashboard.png" alt="Demo" width="1169" height="835" />

</center>
- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing.
- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard.

#### 🐛 CLI debugger for agents

Coding agents can now run `npx playwright test --debug=cli` to attach and debug tests over `playwright-cli` — perfect for automatically fixing tests in agentic workflows:

```bash
$ npx playwright test --debug=cli

### Debugging Instructions
- Run "playwright-cli attach tw-87b59e" to attach to this test

$ playwright-cli attach tw-87b59e

### Session `tw-87b59e` created, attached to `tw-87b59e`.
Run commands with: playwright-cli --session=tw-87b59e <command>

### Paused
- Navigate to "/" at output/tests/example.spec.ts:4

$ playwright-cli --session tw-87b59e step-over

### Page
- Page URL: https://playwright.dev/
- Page Title: Fast and reliable end-to-end testing for modern web apps | Playwright

### Paused
- Expect "toHaveTitle" at output/tests/example.spec.ts:7
```

#### 📋 CLI trace analysis for agents

Coding agents can run `npx playwright trace` to explore [Playwright Trace](https://playwright.dev/docs/trace-viewer) and understand failing or flaky tests from the command line:

```bash
$ npx playwright trace open test-results/example-has-title-chromium/trace.zip
  Title:        example.spec.ts:3 › has title

$ npx playwright trace actions --grep="expect"
     # Time       Action                                                  Duration
  ──── ─────────  ─────────────────────────────────────────────────────── ────────
    9. 0:00.859  Expect "toHaveTitle"                                        5.1s  ✗

$ npx playwright trace action 9
  Expect "toHaveTitle"
  Error: expect(page).toHaveTitle(expected) failed
    Expected pattern: /Wrong Title/
    Received string:  "Fast and reliable end-to-end testing for modern web apps | Playwright"
    Timeout: 5000ms
  Snapshots
    available: before, after
    usage:     npx playwright trace snapshot 9 --name <before|after>

$ npx playwright trace snapshot 9 --name after

### Page
- Page Title: Fast and reliable end-to-end testing for modern web apps | Playwright

$ npx playwright trace close
```

#### ♻️ `await using`

Many APIs now return [async disposables](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncDispose), enabling the `await using` syntax for automatic cleanup:

```js
await using page = await context.newPage();
{
  await using route = await page.route('**/*', route => route.continue());
  await using script = await page.addInitScript('console.log("init script here")');
  await page.goto('https://playwright.dev');
  // do something
}
// route and init script have been removed at this point
```

#### 🔍 Snapshots and Locators

- Method [page.ariaSnapshot()](https://playwright.dev/docs/api/class-page#page-aria-snapshot) to capture the aria snapshot of the page — equivalent to `page.locator('body').ariaSnapshot()`.
- Options `depth` and `mode` in [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
- Method [locator.normalize()](https://playwright.dev/docs/api/class-locator#locator-normalize) converts a locator to follow best practices like test ids and aria roles.
- Method [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) enters an interactive mode where hovering over elements highlights them and shows the corresponding locator. Click an element to get its [Locator](https://playwright.dev/docs/api/class-locator) back. Use [page.cancelPickLocator()](https://playwright.dev/docs/api/class-page#page-cancel-pick-locator) to cancel.

#### New APIs

##### Screencast

- [page.screencast](https://playwright.dev/docs/api/class-page#page-screencast) provides video recording, real-time frame streaming, and overlay management.
- Methods [screencast.start()](https://playwright.dev/docs/api/class-screencast#screencast-start) and [screencast.stop()](https://playwright.dev/docs/api/class-screencast#screencast-stop) for recording and frame capture.
- Methods [screencast.showActions()](https://playwright.dev/docs/api/class-screencast#screencast-show-actions) and [screencast.hideActions()](https://playwright.dev/docs/api/class-screencast#screencast-hide-actions) for action annotations.
- Methods [screencast.showChapter()](https://playwright.dev/docs/api/class-screencast#screencast-show-chapter) and [screencast.showOverlay()](https://playwright.dev/docs/api/class-screencast#screencast-show-overlay) for visual overlays.
- Methods [screencast.showOverlays()](https://playwright.dev/docs/api/class-screencast#screencast-show-overlays) and [screencast.hideOverlays()](https://playwright.dev/docs/api/class-screencast#screencast-hide-overlays) for overlay visibility control.

##### Storage, Console and Errors

- Method [browserContext.setStorageState()](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state) clears existing cookies, local storage, and IndexedDB for all origins and sets a new storage state — no need to create a new context.
- Methods [page.clearConsoleMessages()](https://playwright.dev/docs/api/class-page#page-clear-console-messages) and [page.clearPageErrors()](https://playwright.dev/docs/api/class-page#page-clear-page-errors) to clear stored messages and errors.
- Option `filter` in [page.consoleMessages()](https://playwright.dev/docs/api/class-page#page-console-messages) and [page.pageErrors()](https://playwright.dev/docs/api/class-page#page-page-errors) controls which messages are returned.
- Method [consoleMessage.timestamp()](https://playwright.dev/docs/api/class-consolemessage#console-message-timestamp).

##### Miscellaneous

- [browserContext.debugger](https://playwright.dev/docs/api/class-browsercontext#browser-context-debugger) provides programmatic control over the Playwright debugger.
- Method [browserContext.isClosed()](https://playwright.dev/docs/api/class-browsercontext#browser-context-is-closed).
- Method [request.existingResponse()](https://playwright.dev/docs/api/class-request#request-existing-response) returns the response without waiting.
- Method [response.httpVersion()](https://playwright.dev/docs/api/class-response#response-http-version) returns the HTTP version used by the response.
- Events [cdpSession.on('event')](https://playwright.dev/docs/api/class-cdpsession#cdp-session-event-event) and [cdpSession.on('close')](https://playwright.dev/docs/api/class-cdpsession#cdp-session-event-close) for CDP sessions.
- Option `live` in [tracing.start()](https://playwright.dev/docs/api/class-tracing#tracing-start) for real-time trace updates.
- Option `artifactsDir` in [browserType.launch()](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) to configure the artifacts directory.

#### 🛠️ Other improvements

- UI Mode has an option to only show tests affected by source changes.
- UI Mode and Trace Viewer have improved action filtering.
- HTML Reporter shows the list of runs from the same worker.
- HTML Reporter allows filtering test steps for quick search.
- New trace mode `'retain-on-failure-and-retries'` records a trace for each test run and retains all traces when an attempt fails — great for comparing a passing trace with a failing one from a flaky test.

#### Known Issues ⚠️⚠️

- `navigator.platform` emulation can cause Ctrl or Meta dispatching errors ([#&#8203;40009](https://github.com/microsoft/playwright/issues/40009)). Pass `PLAYWRIGHT_NO_UA_PLATFORM = '1'` environment variable while we are issuing a patch release. Let us know in the issue how it affected you.

#### Breaking Changes ⚠️

- Removed macOS 14 support for WebKit. We recommend upgrading your macOS version, or keeping an older Playwright version.
- Removed `@playwright/experimental-ct-svelte` package.

#### Browser Versions

- Chromium 147.0.7727.15
- Mozilla Firefox 148.0.2
- WebKit 26.4

This version was also tested against the following stable channels:

- Google Chrome 146
- Microsoft Edge 146

</details>

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.4`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.4)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

#####    🚀 Experimental Features

- **coverage**:
  - Default to text reporter `skipFull` if agent detected  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10018](https://github.com/vitest-dev/vitest/issues/10018) [<samp>(53757)</samp>](https://github.com/vitest-dev/vitest/commit/53757804c)
- **experimental**:
  - Expose `assertion` as a public field  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10095](https://github.com/vitest-dev/vitest/issues/10095) [<samp>(a120e)</samp>](https://github.com/vitest-dev/vitest/commit/a120e3ab8)
  - Support aria snapshot  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6 (1M context)**, [@&#8203;AriPerkkio](https://github.com/AriPerkkio), **Codex** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9668](https://github.com/vitest-dev/vitest/issues/9668) [<samp>(d4fbb)</samp>](https://github.com/vitest-dev/vitest/commit/d4fbb5cc9)
- **reporter**:
  - Add filterMeta option to json reporter  -  by [@&#8203;nami8824](https://github.com/nami8824) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10078](https://github.com/vitest-dev/vitest/issues/10078) [<samp>(b77de)</samp>](https://github.com/vitest-dev/vitest/commit/b77de968e)

#####    🐞 Bug Fixes

- Use "black" foreground for labeled terminal message to ensure contrast  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10076](https://github.com/vitest-dev/vitest/issues/10076) [<samp>(203f0)</samp>](https://github.com/vitest-dev/vitest/commit/203f07af7)
- Make `expect(..., message)` consistent as error message prefix  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10068](https://github.com/vitest-dev/vitest/issues/10068) [<samp>(a1b5f)</samp>](https://github.com/vitest-dev/vitest/commit/a1b5f0f4f)
- Do not hoist imports whose names match class properties .  -  by [@&#8203;SunsetFi](https://github.com/SunsetFi) in [#&#8203;10093](https://github.com/vitest-dev/vitest/issues/10093) and [#&#8203;10094](https://github.com/vitest-dev/vitest/issues/10094) [<samp>(0fc4b)</samp>](https://github.com/vitest-dev/vitest/commit/0fc4b47e0)
- **browser**: Spread user server options into browser Vite server in project  -  by [@&#8203;GoldStrikeArch](https://github.com/GoldStrikeArch) in [#&#8203;10049](https://github.com/vitest-dev/vitest/issues/10049) [<samp>(65c9d)</samp>](https://github.com/vitest-dev/vitest/commit/65c9d55eb)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

### [`v4.1.3`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.3)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

#####    🚀 Experimental Features

- Add `experimental.preParse` flag  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10070](https://github.com/vitest-dev/vitest/issues/10070) [<samp>(78273)</samp>](https://github.com/vitest-dev/vitest/commit/7827363bd)
- Support `browser.locators.exact` option  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10013](https://github.com/vitest-dev/vitest/issues/10013) [<samp>(48799)</samp>](https://github.com/vitest-dev/vitest/commit/487990a19)
- Add `TestAttachment.bodyEncoding`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9969](https://github.com/vitest-dev/vitest/issues/9969) [<samp>(89ca0)</samp>](https://github.com/vitest-dev/vitest/commit/89ca0e254)
- Support custom snapshot matcher  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Sonnet 4.6** and **Codex** in [#&#8203;9973](https://github.com/vitest-dev/vitest/issues/9973) [<samp>(59b0e)</samp>](https://github.com/vitest-dev/vitest/commit/59b0e6411)

#####    🐞 Bug Fixes

- Advance fake timers with `expect.poll` interval  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10022](https://github.com/vitest-dev/vitest/issues/10022) [<samp>(3f5bf)</samp>](https://github.com/vitest-dev/vitest/commit/3f5bfa365)
- Add `@vitest/coverage-v8` and `@vitest/coverage-istanbul` as optional dependency  -  by [@&#8203;alan-agius4](https://github.com/alan-agius4) in [#&#8203;10025](https://github.com/vitest-dev/vitest/issues/10025) [<samp>(146d4)</samp>](https://github.com/vitest-dev/vitest/commit/146d4f0a0)
- Fix `defineHelper` for webkit async stack trace + update playwright 1.59.0  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10036](https://github.com/vitest-dev/vitest/issues/10036) [<samp>(5a5fa)</samp>](https://github.com/vitest-dev/vitest/commit/5a5fa49fe)
- Fix suite hook throwing errors for unused auto test-scoped fixture  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10035](https://github.com/vitest-dev/vitest/issues/10035) [<samp>(39865)</samp>](https://github.com/vitest-dev/vitest/commit/398657e8d)
- **expect**:
  - Remove `JestExtendError.context` from verbose error reporting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9983](https://github.com/vitest-dev/vitest/issues/9983) [<samp>(66751)</samp>](https://github.com/vitest-dev/vitest/commit/66751c9e8)
  - Don't leak "runner" types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10004](https://github.com/vitest-dev/vitest/issues/10004) [<samp>(ec204)</samp>](https://github.com/vitest-dev/vitest/commit/ec2045543)
- **snapshot**:
  - Fix flagging obsolete snapshots for snapshot properties mismatch  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;9986](https://github.com/vitest-dev/vitest/issues/9986) [<samp>(6b869)</samp>](https://github.com/vitest-dev/vitest/commit/6b869156b)
  - Export custom snapshot matcher helper from `vitest`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10042](https://github.com/vitest-dev/vitest/issues/10042) [<samp>(691d3)</samp>](https://github.com/vitest-dev/vitest/commit/691d341fd)
- **ui**:
  - Don't leak vite types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10005](https://github.com/vitest-dev/vitest/issues/10005) [<samp>(fdff1)</samp>](https://github.com/vitest-dev/vitest/commit/fdff1bf9a)
- **vm**:
  - Fix external module resolve error with deps optimizer query  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10024](https://github.com/vitest-dev/vitest/issues/10024) [<samp>(9dbf4)</samp>](https://github.com/vitest-dev/vitest/commit/9dbf47786)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

### [`v4.1.2`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.2)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

This release bumps Vitest's `flatted` version and removes version pinning to resolve `flatted`'s CVE related issues ([#&#8203;9975](https://github.com/vitest-dev/vitest/issues/9975)).

#####    🐞 Bug Fixes

- Don't resolve `setupFiles` from parent directory  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9960](https://github.com/vitest-dev/vitest/issues/9960) [<samp>(7aa93)</samp>](https://github.com/vitest-dev/vitest/commit/7aa937776)
- Ensure sequential mock/unmock resolution  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9830](https://github.com/vitest-dev/vitest/issues/9830) [<samp>(7c065)</samp>](https://github.com/vitest-dev/vitest/commit/7c06598db)
- **browser**: Take failure screenshot if `toMatchScreenshot` can't capture a stable screenshot  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9847](https://github.com/vitest-dev/vitest/issues/9847) [<samp>(faace)</samp>](https://github.com/vitest-dev/vitest/commit/faace1fbe)
- **coverage**: Correct `coverageConfigDefaults` values and types  -  by [@&#8203;Arthie](https://github.com/Arthie) in [#&#8203;9940](https://github.com/vitest-dev/vitest/issues/9940) [<samp>(b3c99)</samp>](https://github.com/vitest-dev/vitest/commit/b3c992cb2)
- **pretty-format**: Fix output limit over counting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9965](https://github.com/vitest-dev/vitest/issues/9965) [<samp>(d3b7a)</samp>](https://github.com/vitest-dev/vitest/commit/d3b7a40fa)
- Disable colors if agent is detected  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9851](https://github.com/vitest-dev/vitest/issues/9851) [<samp>(6f97b)</samp>](https://github.com/vitest-dev/vitest/commit/6f97b55dd)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

### [`v4.1.1`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.1)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

#####    🚀 Features

- **experimental**:
  - Expose `matchesTags` to test if the current filter matches tags  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9913](https://github.com/vitest-dev/vitest/issues/9913) [<samp>(eec53)</samp>](https://github.com/vitest-dev/vitest/commit/eec53d9f5)
  - Introduce `experimental.vcsProvider`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9928](https://github.com/vitest-dev/vitest/issues/9928) [<samp>(56115)</samp>](https://github.com/vitest-dev/vitest/commit/561150036)

#####    🐞 Bug Fixes

- Mark `TestProject.testFilesList` internal properly  -  by [@&#8203;sapphi-red](https://github.com/sapphi-red) in [#&#8203;9867](https://github.com/vitest-dev/vitest/issues/9867) [<samp>(54f26)</samp>](https://github.com/vitest-dev/vitest/commit/54f2660f5)
- Detect fixture that returns without calling `use`  -  by [@&#8203;oilater](https://github.com/oilater) in [#&#8203;9831](https://github.com/vitest-dev/vitest/issues/9831) and [#&#8203;9861](https://github.com/vitest-dev/vitest/issues/9861) [<samp>(633ae)</samp>](https://github.com/vitest-dev/vitest/commit/633ae2303)
- Drop vite 8.beta support  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9862](https://github.com/vitest-dev/vitest/issues/9862) [<samp>(b78f5)</samp>](https://github.com/vitest-dev/vitest/commit/b78f5389d)
- Type regression in vi.mocked() static class methods  -  by [@&#8203;purepear](https://github.com/purepear) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9857](https://github.com/vitest-dev/vitest/issues/9857) [<samp>(90926)</samp>](https://github.com/vitest-dev/vitest/commit/90926641b)
- Properly re-evaluate actual modules of mocked external  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9898](https://github.com/vitest-dev/vitest/issues/9898) [<samp>(ae5ec)</samp>](https://github.com/vitest-dev/vitest/commit/ae5ec03ef)
- Preserve coverage report when html reporter overlaps  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9889](https://github.com/vitest-dev/vitest/issues/9889) [<samp>(2d81a)</samp>](https://github.com/vitest-dev/vitest/commit/2d81ad897)
- Provide `vi.advanceTimers` to the preview provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9891](https://github.com/vitest-dev/vitest/issues/9891) [<samp>(1bc3e)</samp>](https://github.com/vitest-dev/vitest/commit/1bc3e63be)
- Don't leak event listener in playwright provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9910](https://github.com/vitest-dev/vitest/issues/9910) [<samp>(d9355)</samp>](https://github.com/vitest-dev/vitest/commit/d93550ff7)
- Open browser in `--standalone` mode without running tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9911](https://github.com/vitest-dev/vitest/issues/9911) [<samp>(e78ad)</samp>](https://github.com/vitest-dev/vitest/commit/e78adcf97)
- Guard disposable and optional `body`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9912](https://github.com/vitest-dev/vitest/issues/9912) [<samp>(6fdb2)</samp>](https://github.com/vitest-dev/vitest/commit/6fdb2ba61)
- Resolve `retry.condition` RegExp serialization issue  -  by [@&#8203;nstepien](https://github.com/nstepien) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9942](https://github.com/vitest-dev/vitest/issues/9942) [<samp>(7b605)</samp>](https://github.com/vitest-dev/vitest/commit/7b6054328)
- **collect**:
  - Don't treat extra props on `test` return as tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9871](https://github.com/vitest-dev/vitest/issues/9871) [<samp>(141e7)</samp>](https://github.com/vitest-dev/vitest/commit/141e72aa1)
- **coverage**:
  - Simplify provider types  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9931](https://github.com/vitest-dev/vitest/issues/9931) [<samp>(aaf9f)</samp>](https://github.com/vitest-dev/vitest/commit/aaf9f18ae)
  - Load built-in provider without module runner  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9939](https://github.com/vitest-dev/vitest/issues/9939) [<samp>(bf892)</samp>](https://github.com/vitest-dev/vitest/commit/bf8920817)
- **expect**:
  - Soft assertions continue after .resolves/.rejects promise errors  -  by [@&#8203;mixelburg](https://github.com/mixelburg), **Maks Pikov**, **Claude Opus 4.6 (1M context)** and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9843](https://github.com/vitest-dev/vitest/issues/9843) [<samp>(6d74b)</samp>](https://github.com/vitest-dev/vitest/commit/6d74b4948)
  - Fix sinon-chai style API  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9943](https://github.com/vitest-dev/vitest/issues/9943) [<samp>(0f08d)</samp>](https://github.com/vitest-dev/vitest/commit/0f08dda2c)
- **pretty-format**:
  - Limit output for large object  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6 (1M context)** in [#&#8203;9949](https://github.com/vitest-dev/vitest/issues/9949) [<samp>(0d5f9)</samp>](https://github.com/vitest-dev/vitest/commit/0d5f9d6ef)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

</details>

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

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

[Compare Source](https://github.com/eslint/eslint/compare/v10.1.0...v10.2.0)

#### Features

- [`586ec2f`](https://github.com/eslint/eslint/commit/586ec2f43092779acc957866db4abe999112d1e1) feat: Add `meta.languages` support to rules ([#&#8203;20571](https://github.com/eslint/eslint/issues/20571)) (Copilot)
- [`14207de`](https://github.com/eslint/eslint/commit/14207dee3939dc87cfa8b2fcfc271fff2cfd6471) feat: add `Temporal` to `no-obj-calls` ([#&#8203;20675](https://github.com/eslint/eslint/issues/20675)) (Pixel998)
- [`bbb2c93`](https://github.com/eslint/eslint/commit/bbb2c93a2b31bd30924f32fe69a9acf41f9dfe35) feat: add Temporal to ES2026 globals ([#&#8203;20672](https://github.com/eslint/eslint/issues/20672)) (Pixel998)

#### Bug Fixes

- [`542cb3e`](https://github.com/eslint/eslint/commit/542cb3e6442a4e6ee3457c799e2a0ee23bef0c6a) fix: update first-party dependencies ([#&#8203;20714](https://github.com/eslint/eslint/issues/20714)) (Francesco Trotta)

#### Documentation

- [`a2af743`](https://github.com/eslint/eslint/commit/a2af743ea60f683d0e0de9d98267c1e7e4f5e412) docs: add `language` to configuration objects ([#&#8203;20712](https://github.com/eslint/eslint/issues/20712)) (Francesco Trotta)
- [`845f23f`](https://github.com/eslint/eslint/commit/845f23f1370892bf07d819497ac518c9e65090d6) docs: Update README (GitHub Actions Bot)
- [`5fbcf59`](https://github.com/eslint/eslint/commit/5fbcf5958b897cc4df5d652924d18428db37f7ee) docs: remove `sourceType` from ts playground link ([#&#8203;20477](https://github.com/eslint/eslint/issues/20477)) (Tanuj Kanti)
- [`8702a47`](https://github.com/eslint/eslint/commit/8702a474659be786b6b1392e5e7c0c56355ae4a4) docs: Update README (GitHub Actions Bot)
- [`ddeaded`](https://github.com/eslint/eslint/commit/ddeaded2ab36951383ff67c60fb64ec68d29a46a) docs: Update README (GitHub Actions Bot)
- [`2b44966`](https://github.com/eslint/eslint/commit/2b4496691266547784a7f7ad1989ce53381bab91) docs: add Major Releases section to Manage Releases ([#&#8203;20269](https://github.com/eslint/eslint/issues/20269)) (Milos Djermanovic)
- [`eab65c7`](https://github.com/eslint/eslint/commit/eab65c700ebb16a6e790910c720450c9908961fd) docs: update `eslint` versions in examples ([#&#8203;20664](https://github.com/eslint/eslint/issues/20664)) (루밀LuMir)
- [`3e4a299`](https://github.com/eslint/eslint/commit/3e4a29903bf31f0998e45ad9128a265bce1edc56) docs: update ESM Dependencies policies with note for own-usage packages ([#&#8203;20660](https://github.com/eslint/eslint/issues/20660)) (Milos Djermanovic)

#### Chores

- [`8120e30`](https://github.com/eslint/eslint/commit/8120e30f833474f47acc061d24d164e9f022264f) refactor: extract no unmodified loop condition ([#&#8203;20679](https://github.com/eslint/eslint/issues/20679)) (kuldeep kumar)
- [`46e8469`](https://github.com/eslint/eslint/commit/46e8469786be1b2bbb522100e1d44624d98d3745) chore: update dependency markdownlint-cli2 to ^0.22.0 ([#&#8203;20697](https://github.com/eslint/eslint/issues/20697)) (renovate\[bot])
- [`01ed3aa`](https://github.com/eslint/eslint/commit/01ed3aa68477f81a7188e1498cf4906e02015b7c) test: add unit tests for unicode utilities ([#&#8203;20622](https://github.com/eslint/eslint/issues/20622)) (Manish chaudhary)
- [`811f493`](https://github.com/eslint/eslint/commit/811f4930f82ee2b6ac8eae75cade9bed63de0781) ci: remove `--legacy-peer-deps` from types integration tests ([#&#8203;20667](https://github.com/eslint/eslint/issues/20667)) (Milos Djermanovic)
- [`6b86fcf`](https://github.com/eslint/eslint/commit/6b86fcfc5c75d6a3b8a2cf7bcdb3ef60635a9a03) chore: update dependency npm-run-all2 to v8 ([#&#8203;20663](https://github.com/eslint/eslint/issues/20663)) (renovate\[bot])
- [`632c4f8`](https://github.com/eslint/eslint/commit/632c4f83bf32b77981c7d395cacddd1bb172ee25) chore: add `prettier` update commit to `.git-blame-ignore-revs` ([#&#8203;20662](https://github.com/eslint/eslint/issues/20662)) (루밀LuMir)
- [`b0b0f21`](https://github.com/eslint/eslint/commit/b0b0f21927e03ba092400e3c70d7058f537765c8) chore: update dependency eslint-plugin-regexp to ^3.1.0 ([#&#8203;20659](https://github.com/eslint/eslint/issues/20659)) (Milos Djermanovic)
- [`228a2dd`](https://github.com/eslint/eslint/commit/228a2dd4b272c17f516ee3541f1dd69eca0a8ab0) chore: update dependency eslint-plugin-eslint-plugin to ^7.3.2 ([#&#8203;20661](https://github.com/eslint/eslint/issues/20661)) (Milos Djermanovic)
- [`3ab4d7e`](https://github.com/eslint/eslint/commit/3ab4d7e244df244102de9d0d250b2ff12456a785) test: Add tests for eslintrc-style keys ([#&#8203;20645](https://github.com/eslint/eslint/issues/20645)) (kuldeep kumar)

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

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.3...v10.1.0)

#### Features

- [`ff4382b`](https://github.com/eslint/eslint/commit/ff4382be349035acdb170627a2dc92828e134562) feat: apply fix for `no-var` in `TSModuleBlock` ([#&#8203;20638](https://github.com/eslint/eslint/issues/20638)) (Tanuj Kanti)
- [`0916995`](https://github.com/eslint/eslint/commit/0916995b51528872b15ba4fedb24172cf25fcb3f) feat: Implement api support for bulk-suppressions ([#&#8203;20565](https://github.com/eslint/eslint/issues/20565)) (Blake Sager)

#### Bug Fixes

- [`2b8824e`](https://github.com/eslint/eslint/commit/2b8824e6be4223980e929a20025602df20d05ea2) fix: Prevent `no-var` autofix when a variable is used before declaration ([#&#8203;20464](https://github.com/eslint/eslint/issues/20464)) (Amaresh  S M)
- [`e58b4bf`](https://github.com/eslint/eslint/commit/e58b4bff167e79afd067d1b0ee9360bec2d3393e) fix: update eslint ([#&#8203;20597](https://github.com/eslint/eslint/issues/20597)) (renovate\[bot])

#### Documentation

- [`b7b57fe`](https://github.com/eslint/eslint/commit/b7b57fe9942c572ff651230f1f96cefed787ca52) docs: use correct JSDoc link in require-jsdoc.md ([#&#8203;20641](https://github.com/eslint/eslint/issues/20641)) (mkemna-clb)
- [`58e4cfc`](https://github.com/eslint/eslint/commit/58e4cfc7dbf0fe40c73f09bf0ff94ad944d0ba0e) docs: add deprecation notice partial ([#&#8203;20639](https://github.com/eslint/eslint/issues/20639)) (Milos Djermanovic)
- [`7143dbf`](https://github.com/eslint/eslint/commit/7143dbf99df27c61edf1552da981794e99a0b2f2) docs: update v9 migration guide for `@eslint/js` usage ([#&#8203;20540](https://github.com/eslint/eslint/issues/20540)) (fnx)
- [`035fc4f`](https://github.com/eslint/eslint/commit/035fc4fbe506e3e4524882cf50db37a4e430adf4) docs: note that `globalReturn` applies only with `sourceType: "script"` ([#&#8203;20630](https://github.com/eslint/eslint/issues/20630)) (Milos Djermanovic)
- [`e972c88`](https://github.com/eslint/eslint/commit/e972c88ab7474a74191ee99ac2558b00d0427a8a) docs: merge ESLint option descriptions into type definitions ([#&#8203;20608](https://github.com/eslint/eslint/issues/20608)) (Francesco Trotta)
- [`7f10d84`](https://github.com/eslint/eslint/commit/7f10d8440137f0cfd75f18f4746ba6a1c621b953) docs: Update README (GitHub Actions Bot)
- [`aeed007`](https://github.com/eslint/eslint/commit/aeed0078ca2f73d4744cc522102178d45b5be64e) docs: open playground link in new tab ([#&#8203;20602](https://github.com/eslint/eslint/issues/20602)) (Tanuj Kanti)
- [`a0d1a37`](https://github.com/eslint/eslint/commit/a0d1a3772679d3d74bb860fc65b5b58678acd452) docs: Add AI Usage Policy ([#&#8203;20510](https://github.com/eslint/eslint/issues/20510)) (Nicholas C. Zakas)

#### Chores

- [`a9f9cce`](https://github.com/eslint/eslint/commit/a9f9cce82d80b540a0e3549d0e91c16df28740d8) chore: update dependency eslint-plugin-unicorn to ^63.0.0 ([#&#8203;20584](https://github.com/eslint/eslint/issues/20584)) (Milos Djermanovic)
- [`1f42bd7`](https://github.com/eslint/eslint/commit/1f42bd7876ae4192cf7f7f4faf73b4ef3d2563cb) chore: update `prettier` to 3.8.1 ([#&#8203;20651](https://github.com/eslint/eslint/issues/20651)) (루밀LuMir)
- [`c0a6f4a`](https://github.com/eslint/eslint/commit/c0a6f4a2b4169edeca2a81bf7b47783e39ade366) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to ^1.2.0 ([#&#8203;20652](https://github.com/eslint/eslint/issues/20652)) (renovate\[bot])
- [`cc43f79`](https://github.com/eslint/eslint/commit/cc43f795c42e5ec2f19bb43b1f6d534ef2e469f3) chore: update dependency c8 to v11 ([#&#8203;20650](https://github.com/eslint/eslint/issues/20650)) (renovate\[bot])
- [`2ce4635`](https://github.com/eslint/eslint/commit/2ce4635b036ff2665c7009afddf9c0fb2274dceb) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to v1 ([#&#8203;20649](https://github.com/eslint/eslint/issues/20649)) (renovate\[bot])
- [`f0406ee`](https://github.com/eslint/eslint/commit/f0406eedcc3dc415babbbf6bbdb5db1eebfd487b) chore: update dependency markdownlint-cli2 to ^0.21.0 ([#&#8203;20646](https://github.com/eslint/eslint/issues/20646)) (renovate\[bot])
- [`dbb4c95`](https://github.com/eslint/eslint/commit/dbb4c9582a00bac604d5c6ac671bb7111468a846) chore: remove trunk ([#&#8203;20478](https://github.com/eslint/eslint/issues/20478)) (sethamus)
- [`c672a2a`](https://github.com/eslint/eslint/commit/c672a2a70579fddf1c6ce33dfa712d705726e1c9) test: fix CLI test for empty output file ([#&#8203;20640](https://github.com/eslint/eslint/issues/20640)) (kuldeep kumar)
- [`c7ada24`](https://github.com/eslint/eslint/commit/c7ada2455680036bbfc42fcb1511ff28afe3c587) ci: bump pnpm/action-setup from 4.3.0 to 4.4.0 ([#&#8203;20636](https://github.com/eslint/eslint/issues/20636)) (dependabot\[bot])
- [`07c4b8b`](https://github.com/eslint/eslint/commit/07c4b8b4a9f49145e60a3448dd57853213ed4de3) test: fix `RuleTester` test without test runners ([#&#8203;20631](https://github.com/eslint/eslint/issues/20631)) (Francesco Trotta)
- [`079bba7`](https://github.com/eslint/eslint/commit/079bba7ff17d0a99fdffe32bf991d005ba797fae) test: Add tests for `isValidWithUnicodeFlag` ([#&#8203;20601](https://github.com/eslint/eslint/issues/20601)) (Manish chaudhary)
- [`5885ae6`](https://github.com/eslint/eslint/commit/5885ae66216bcee9310bbf73786b7d7d5774aeaf) ci: unpin Node.js 25.x in CI ([#&#8203;20615](https://github.com/eslint/eslint/issues/20615)) (Copilot)
- [`f65e5d3`](https://github.com/eslint/eslint/commit/f65e5d3c0df65fdb317ad6d23f7ae113c5f4b6d7) chore: update pnpm/action-setup digest to [`b906aff`](https://github.com/eslint/eslint/commit/b906aff) ([#&#8203;20610](https://github.com/eslint/eslint/issues/20610)) (renovate\[bot])

</details>

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.5.12`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/12b041ddeffa8b37ea44d572284c00a89148f2d3...5d8a8916a9310c4e5fad74040b9d1382c7b54b17)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/12b041ddeffa8b37ea44d572284c00a89148f2d3...5d8a8916a9310c4e5fad74040b9d1382c7b54b17)

### [`v5.5.11`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.10...12b041ddeffa8b37ea44d572284c00a89148f2d3)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.10...12b041ddeffa8b37ea44d572284c00a89148f2d3)

### [`v5.5.10`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.10): performance improvment, increase entity expansion default limit

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.9...v5.5.10)

- increase default entity explansion limit as many projects demand for that

```
maxEntitySize: 10000,
maxExpansionDepth: 10000,
maxTotalExpansions: Infinity,
maxExpandedLength: 100000,
maxEntityCount: 1000,
```

- performance improvement
  - reduce calls to toString
  - early return when entities are not present
  - prepare rawAttrsForMatcher only if user sets `jPath: false`

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.9...v5.5.10>

### [`v5.5.9`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.9): fix typins and matcher instance in callbacks

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.8...v5.5.9)

combine typings file to avoid configuration changes
pass readonly instance of matcher to the call backs to avoid accidental push/pop call

### [`v5.5.8`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.7...v5.5.8)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.7...v5.5.8)

### [`v5.5.7`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.7): fix bugs of entity parsing and value parsing

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.6...v5.5.7)

fix: entity expansion limits
update strnum package to 2.2.0

### [`v5.5.6`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.6): fix entity expansion and incorrect replacement and performance

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.5...v5.5.6)

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.5...v5.5.6>

### [`v5.5.5`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.5): support onDangerousProperty

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.4...v5.5.5)

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.5>

### [`v5.5.4`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.4)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.4)

</details>

<details>
<summary>capricorn86/happy-dom (happy-dom)</summary>

### [`v20.9.0`](https://github.com/capricorn86/happy-dom/releases/tag/v20.9.0)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.9...v20.9.0)

##### 🎨 Features

- Adds support for event listener properties on Window (e.g. `Window.onkeydown`) - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2131](https://github.com/capricorn86/happy-dom/issues/2131)

### [`v20.8.9`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.9)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.8...v20.8.9)

##### :construction\_worker\_man: Patch fixes

- Fixes issue where cookies from the current origin was being forwarded to the target origin in fetch requests - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2117](https://github.com/capricorn86/happy-dom/issues/2117)
  - A security advisory ([GHSA-w4gp-fjgq-3q4g](https://github.com/capricorn86/happy-dom/security/advisories/GHSA-w4gp-fjgq-3q4g)) was reported for this security vulnerability. Big thanks to [@&#8203;r74tech](https://github.com/r74tech) for reporting this!

### [`v20.8.8`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.8)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.7...v20.8.8)

##### :construction\_worker\_man: Patch fixes

- Fixes issue where export names can be interpolated as executable code in ESM - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2113](https://github.com/capricorn86/happy-dom/issues/2113)
  - A security advisory ([GHSA-6q6h-j7hj-3r64](https://github.com/capricorn86/happy-dom/security/advisories/GHSA-6q6h-j7hj-3r64)) has been reported that shows a security vulnerability where it may be possible to escape the VM context and get access to process level functionality in unsafe environments using CommonJS. Big thanks to [@&#8203;tndud042713](https://github.com/tndud042713) for reporting this!

### [`v20.8.7`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.7)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.6...v20.8.7)

##### :construction\_worker\_man: Patch fixes

- Replace implementing Node.js `Console` with common `IConsole` interface to support latest version of Bun - By **[@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)** in task [#&#8203;1845](https://github.com/capricorn86/happy-dom/issues/1845)

### [`v20.8.6`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.6)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.5...v20.8.6)

##### :construction\_worker\_man: Patch fixes

- `Request.formData()` should honor "Content-Type" header - By **[@&#8203;brianhelba](https://github.com/brianhelba)** in task [#&#8203;2106](https://github.com/capricorn86/happy-dom/issues/2106)

### [`v20.8.5`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.5)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.4...v20.8.5)

##### :construction\_worker\_man: Patch fixes

- Fixes error thrown when modifying DOM structure in `connectedCallback()` - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2110](https://github.com/capricorn86/happy-dom/issues/2110)

### [`v20.8.4`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.4)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.3...v20.8.4)

##### :construction\_worker\_man: Patch fixes

- Replace ConsoleConstructor import with indexed access type - By **[@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)** in task [#&#8203;1845](https://github.com/capricorn86/happy-dom/issues/1845)

</details>

<details>
<summary>video-dev/hls.js (hls.js)</summary>

### [`v1.6.16`](https://github.com/video-dev/hls.js/releases/tag/v1.6.16)

[Compare Source](https://github.com/video-dev/hls.js/compare/v1.6.15...v1.6.16)

### Summary

HLS.js v1.6.16 includes bug fixes and improvements over the last release.

#### Changes Since The Last Release

- Fix Interstitials live start with short sliding window ([#&#8203;7799](https://github.com/video-dev/hls.js/issues/7799))
- Limit buffering while paused outside live sliding window ([#&#8203;7788](https://github.com/video-dev/hls.js/issues/7788))

#### Demo Page

<https://121bff6b.hls-js-dev.pages.dev/demo/>

#### Feedback

Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md).

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.19`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31919)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.18...3.19.19)

- Worker: Ensure 4-byte alignment for network packet receive buffers and test buffers to avoid undefined behavior ([PR #&#8203;1756](https://github.com/versatica/mediasoup/pull/1756).
- Worker: Update liburing from 2.12-1 to 2.14-1 ([PR #&#8203;1761](https://github.com/versatica/mediasoup/pull/1761).

### [`v3.19.18`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31918)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.17...3.19.18)

- Worker: Improve `Utils::Crypto::GetRandomUInt()` ([PR #&#8203;1725](https://github.com/versatica/mediasoup/pull/1725).
- Convert `WORKER_CLOSE` into a notification ([PR #&#8203;1729](https://github.com/versatica/mediasoup/pull/1729).
- Node tests: Replace `sctp` unmaintained library with `werift-sctp` ([PR #&#8203;1732](https://github.com/versatica/mediasoup/pull/1732), thanks to [@&#8203;shinyoshiaki](https://github.com/shinyoshiaki) for his help with `werift-sctp`.
- Worker: Require C++20 ([PR #&#8203;1741](https://github.com/versatica/mediasoup/pull/1741).
- Fix "SCTP failed" if no DataChannel is created on a Transport with `enableSctp: true` ([PR #&#8203;1749](https://github.com/versatica/mediasoup/pull/1749).

</details>

<details>
<summary>panva/openid-client (openid-client)</summary>

### [`v6.8.3`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#683-2026-04-13)

[Compare Source](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3)

##### Documentation

- note a workaround for redirect\_uri with query string or bare origin ([e9689de](https://github.com/panva/openid-client/commit/e9689de336fed2c9e6678ac804f1c6b7d9bbd338)), closes [#&#8203;868](https://github.com/panva/openid-client/issues/868)

##### Fixes

- **passport:** delete one-time state on callback ([1e7dd2e](https://github.com/panva/openid-client/commit/1e7dd2eced03e729447bfa38c8c455268748c5c3))

</details>

<details>
<summary>vuejs/core (vue)</summary>

### [`v3.5.32`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3532-2026-04-03)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.31...v3.5.32)

##### Bug Fixes

- **runtime-core:** prevent currentInstance leak into sibling render during async setup re-entry ([#&#8203;14668](https://github.com/vuejs/core/issues/14668)) ([f166353](https://github.com/vuejs/core/commit/f1663535a163057788d3285dec54a245c3efb3ad)), closes [#&#8203;14667](https://github.com/vuejs/core/issues/14667)
- **teleport:** handle updates before deferred mount ([#&#8203;14642](https://github.com/vuejs/core/issues/14642)) ([32b44f1](https://github.com/vuejs/core/commit/32b44f19f67aa30899817a7e79a4510f3b52970a)), closes [#&#8203;14640](https://github.com/vuejs/core/issues/14640)
- **types:** allow customRef to have different getter/setter types ([#&#8203;14639](https://github.com/vuejs/core/issues/14639)) ([e20ddb0](https://github.com/vuejs/core/commit/e20ddb00188e9935884930046fa572eab7c9dcba))
- **types:** use private branding for shallowReactive ([#&#8203;14641](https://github.com/vuejs/core/issues/14641)) ([302c47a](https://github.com/vuejs/core/commit/302c47a4994bc8b47b8a2af6693d8cb6bbd4b06b)), closes [#&#8203;14638](https://github.com/vuejs/core/issues/14638) [#&#8203;14493](https://github.com/vuejs/core/issues/14493)

##### Reverts

- Revert "fix(server-renderer): cleanup component effect scopes after SSR render" ([#&#8203;14674](https://github.com/vuejs/core/issues/14674)) ([219d83b](https://github.com/vuejs/core/commit/219d83bd305ce6fc052941acaaf02e7bc70616a4)), closes [#&#8203;14674](https://github.com/vuejs/core/issues/14674) [#&#8203;14669](https://github.com/vuejs/core/issues/14669)

### [`v3.5.31`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3531-2026-03-25)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.30...v3.5.31)

##### Bug Fixes

- **compiler-sfc:** allow Node.js subpath imports patterns in asset urls ([#&#8203;13045](https://github.com/vuejs/core/issues/13045)) ([95c3356](https://github.com/vuejs/core/commit/95c33560c9af369d44a7670d0c3b93fb62323be2)), closes [#&#8203;9919](https://github.com/vuejs/core/issues/9919)
- **compiler-sfc:** support template literal as defineModel name ([#&#8203;14622](https://github.com/vuejs/core/issues/14622)) ([bd7eef0](https://github.com/vuejs/core/commit/bd7eef0161d69bccd48ac303bc6a56ba8d718e2d)), closes [#&#8203;14621](https://github.com/vuejs/core/issues/14621)
- **reactivity:** normalize toRef property keys before dep lookup + improve types ([#&#8203;14625](https://github.com/vuejs/core/issues/14625)) ([1bb28d0](https://github.com/vuejs/core/commit/1bb28d011b31bc75c80d2860bc6484cceec1ff20)), closes [#&#8203;12427](https://github.com/vuejs/core/issues/12427) [#&#8203;12431](https://github.com/vuejs/core/issues/12431)
- **runtime-core:** invalidate detached v-for memo vnodes after unmount ([#&#8203;14624](https://github.com/vuejs/core/issues/14624)) ([560def4](https://github.com/vuejs/core/commit/560def426fc38c1710fda7ddd1ac634d67897960)), closes [#&#8203;12708](https://github.com/vuejs/core/issues/12708) [#&#8203;12710](https://github.com/vuejs/core/issues/12710)
- **runtime-core:** preserve nullish event handlers in mergeProps ([#&#8203;14550](https://github.com/vuejs/core/issues/14550)) ([5725222](https://github.com/vuejs/core/commit/5725222a6bc5d1dd032318349ce0c540b1d63a49))
- **runtime-core:** prevent merging model listener when value is null or undefined ([#&#8203;14629](https://github.com/vuejs/core/issues/14629)) ([b39e032](https://github.com/vuejs/core/commit/b39e0329f67354702f4e417e55c15c61d2439657))
- **runtime-dom:** defer teleport mount/update until suspense resolves ([#&#8203;8619](https://github.com/vuejs/core/issues/8619)) ([88ed045](https://github.com/vuejs/core/commit/88ed04501555b9257df8d7ad86d844c2c2136e50)), closes [#&#8203;8603](https://github.com/vuejs/core/issues/8603)
- **runtime-dom:** handle activeElement check in Shadow DOM for v-model ([#&#8203;14196](https://github.com/vuejs/core/issues/14196)) ([959ded2](https://github.com/vuejs/core/commit/959ded22ab7ea1453f607e0964e1fb6748ece6c7))
- **server-renderer:** cleanup component effect scopes after SSR render ([#&#8203;14548](https://github.com/vuejs/core/issues/14548)) ([862f11e](https://github.com/vuejs/core/commit/862f11ee017d51cb9573a8c0642055b3b17cace8))
- **suspense:** avoid unmount activeBranch twice if wrapped in transition ([#&#8203;9392](https://github.com/vuejs/core/issues/9392)) ([908c6ad](https://github.com/vuejs/core/commit/908c6ad05e1c76ae690d2e50f3bd28278af07e22)), closes [#&#8203;7966](https://github.com/vuejs/core/issues/7966)
- **suspense:** update suspense vnode's el during branch self-update ([#&#8203;12922](https://github.com/vuejs/core/issues/12922)) ([a2c1700](https://github.com/vuejs/core/commit/a2c17004c84e5ce3c4e82e35b806ba381144eed3)), closes [#&#8203;12920](https://github.com/vuejs/core/issues/12920)
- **transition:** skip enter guard while hmr updating ([#&#8203;14611](https://github.com/vuejs/core/issues/14611)) ([be0a2f1](https://github.com/vuejs/core/commit/be0a2f1a7fc3d81d05638798cc628848cfa62cef)), closes [#&#8203;14608](https://github.com/vuejs/core/issues/14608)
- **types:** prevent shallowReactive marker from leaking into value unions ([#&#8203;14493](https://github.com/vuejs/core/issues/14493)) ([3b561db](https://github.com/vuejs/core/commit/3b561db4ab42d06166b002f13c0e97cb2bd4a061)), closes [#&#8203;14490](https://github.com/vuejs/core/issues/14490)

</details>

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.4`](https://github.com/vuejs/router/releases/tag/v5.0.4)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.3...v5.0.4)

#####    🐞 Bug Fixes

- Avoid iterator helpers for Node 20 compat  -  by [@&#8203;cwandev](https://github.com/cwandev) in [#&#8203;2635](https://github.com/vuejs/router/issues/2635) [<samp>(47130)</samp>](https://github.com/vuejs/router/commit/4713069d)
- Escape backslahes in string literals  -  by [@&#8203;posva](https://github.com/posva) [<samp>(71fdb)</samp>](https://github.com/vuejs/router/commit/71fdbf36)
- Avoid false duplicate route warning for named views  -  by [@&#8203;posva](https://github.com/posva) [<samp>(72012)</samp>](https://github.com/vuejs/router/commit/72012ab0)
- Allow pushing to auto routes  -  by [@&#8203;posva](https://github.com/posva) [<samp>(47f03)</samp>](https://github.com/vuejs/router/commit/47f0334b)
- **loaders**: Restore context in sequential awaits  -  by [@&#8203;posva](https://github.com/posva) [<samp>(fce5d)</samp>](https://github.com/vuejs/router/commit/fce5d1e3)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.3...v5.0.4)

</details>

<details>
<summary>websockets/ws (ws)</summary>

### [`v8.20.0`](https://github.com/websockets/ws/releases/tag/8.20.0)

[Compare Source](https://github.com/websockets/ws/compare/8.19.0...8.20.0)

### Features

- Added exports for the `PerMessageDeflate` class and utilities for the
  `Sec-WebSocket-Extensions` and `Sec-WebSocket-Protocol` headers ([`d3503c1`](https://github.com/websockets/ws/commit/d3503c1f)).

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDIuMTAiLCJ1cGRhdGVkSW5WZXIiOiI0My4xMjAuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/25
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 02:42:47 +00:00
CI c023bdccae release v1.0.10 [skip ci] 2026-04-15 02:35:40 +00:00
keligrubb 78f3ad8fcc Remove npm overrides for tar (#26)
Push / release-docker-helm (push) Successful in 4m21s
Drop the package.json overrides entry so transitive tar versions follow
what dependencies resolve. Refresh package-lock.json after npm install.

Reviewed-on: #26
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 02:32:49 +00:00
CI 2e7a52ed15 release v1.0.9 [skip ci] 2026-03-24 14:05:10 +00:00
renovate-bot 221b3533e5 chore(deps): update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
Push / release-docker-helm (push) Successful in 5m24s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/setup-helm](https://git.keligrubb.com/actions/setup-helm) | action | major | `v4` → `v5` |

---

### Release Notes

<details>
<summary>actions/setup-helm (https://git.keligrubb.com/actions/setup-helm)</summary>

### [`v5`](https://git.keligrubb.com/actions/setup-helm/compare/v4...v5)

[Compare Source](https://git.keligrubb.com/actions/setup-helm/compare/v4...v5)

</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:eyJjcmVhdGVkSW5WZXIiOiI0My44Ny4xIiwidXBkYXRlZEluVmVyIjoiNDMuODcuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #23
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-03-24 14:02:21 +00:00
CI 578dccd0cf release v1.0.8 [skip ci] 2026-03-12 19:42:02 +00:00
keligrubb 0ecad475ef patch: fix release file (#22)
Push / release-docker-helm (push) Successful in 4m16s
Reviewed-on: #22
2026-03-12 19:39:15 +00:00
keligrubb d5789b79a6 patch: swap to stdlib sqlite3 (#21)
Push / release-docker-helm (push) Failing after 2m39s
Reviewed-on: #21
2026-03-12 19:17:26 +00:00
CI 45ce90f3cc release v1.0.7 [skip ci] 2026-03-06 02:30:45 +00:00
renovate-bot 3a817625c5 chore(deps): update docker/build-push-action action to v7 (#19)
Push / release-docker-helm (push) Successful in 3m33s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [docker/build-push-action](https://github.com/docker/build-push-action) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>docker/build-push-action (docker/build-push-action)</summary>

### [`v7`](https://github.com/docker/build-push-action/compare/v6...v7)

[Compare Source](https://github.com/docker/build-push-action/compare/v6...v7)

</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:eyJjcmVhdGVkSW5WZXIiOiI0My41Ni4xIiwidXBkYXRlZEluVmVyIjoiNDMuNTYuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #19
Reviewed-by: Keli Grubb <keligrubb324@gmail.com>
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-03-06 02:28:31 +00:00
CI d5abea48b3 release v1.0.6 [skip ci] 2026-03-05 15:37:20 +00:00
keligrubb 1da69ac272 patch: fix docker login during push stage (#18)
Push / release-docker-helm (push) Successful in 4m24s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #18
2026-03-05 15:34:32 +00:00
CI bd2092a3ea release v1.0.5 [skip ci] 2026-03-05 14:55:05 +00:00
keligrubb afb6fb8ac7 patch: fix deploy pipeline stages for token registry uploads (#17)
Push / release-docker-helm (push) Failing after 2m56s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #17
2026-03-05 14:52:10 +00:00
CI 414e9f4c33 release v1.0.4 [skip ci] 2026-03-04 20:04:05 +00:00
keligrubb 10035221fb patch: fix deploy pipeline (#15)
Push / release-docker-helm (push) Failing after 2m15s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #15
2026-03-04 20:01:50 +00:00
keligrubb 52a6f4368c patch: migrate to gitea actions (#14)
Push / release-docker-helm (push) Failing after 2s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #14
2026-03-04 19:49:16 +00:00
CI e774dbc301 release v1.0.3 [skip ci] 2026-02-23 03:45:37 +00:00
renovate-bot 5bf582f3ad fix(deps): update dependency vue-router to v5 (#12)
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/) |
|---|---|---|---|
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`^4.4.0` → `^5.0.0`](https://renovatebot.com/diffs/npm/vue-router/4.6.4/5.0.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/4.6.4/5.0.3?slim=true) |

---

### Release Notes

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.3`](https://github.com/vuejs/router/releases/tag/v5.0.3)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.2...v5.0.3)

#####    🚨 Breaking Changes

- **experimental**:
  - Make miss() throw internally and return never  -  by [@&#8203;posva](https://github.com/posva) [<samp>(077e1)</samp>](https://github.com/vuejs/router/commit/077e1740)
  - Add reroute() and deprecate NavigationResult  -  by [@&#8203;posva](https://github.com/posva) [<samp>(308db)</samp>](https://github.com/vuejs/router/commit/308db14a)
  - Remove selectNavigationResult  -  by [@&#8203;posva](https://github.com/posva) [<samp>(9e88a)</samp>](https://github.com/vuejs/router/commit/9e88aed4)

#####    🚀 Features

- Support \_parent in nested folders  -  by [@&#8203;posva](https://github.com/posva) [<samp>(0a37f)</samp>](https://github.com/vuejs/router/commit/0a37f474)
- Warn on \_parent conflict  -  by [@&#8203;posva](https://github.com/posva) [<samp>(182fe)</samp>](https://github.com/vuejs/router/commit/182fe03a)
- Set \_parent as non matchable by default  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8f91c)</samp>](https://github.com/vuejs/router/commit/8f91c99f)
- Warn on conflicting components for routes  -  by [@&#8203;posva](https://github.com/posva) [<samp>(34ace)</samp>](https://github.com/vuejs/router/commit/34aceb98)
- Use type module  -  by [@&#8203;posva](https://github.com/posva) [<samp>(dc9ff)</samp>](https://github.com/vuejs/router/commit/dc9ffe81)
- Add deprecation warning for next() callback in navigation guards  -  by [@&#8203;posva](https://github.com/posva) [<samp>(797f5)</samp>](https://github.com/vuejs/router/commit/797f55de)
- Extract alias from definePage  -  by [@&#8203;posva](https://github.com/posva) [<samp>(835df)</samp>](https://github.com/vuejs/router/commit/835df1ff)
- Display aliases in logs  -  by [@&#8203;posva](https://github.com/posva) [<samp>(7aa60)</samp>](https://github.com/vuejs/router/commit/7aa607fc)
- Deprecate new NavigationResult(to) in favor of reroute(to)  -  by [@&#8203;posva](https://github.com/posva) [<samp>(382e3)</samp>](https://github.com/vuejs/router/commit/382e34b4)
- **experimental**:
  - Handle aliasOf in resolvers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8fe45)</samp>](https://github.com/vuejs/router/commit/8fe453c9)
  - Generate aliases from override in resolver  -  by [@&#8203;posva](https://github.com/posva) [<samp>(a00ac)</samp>](https://github.com/vuejs/router/commit/a00ac355)
  - Warn against non absolute aliases  -  by [@&#8203;posva](https://github.com/posva) [<samp>(476c6)</samp>](https://github.com/vuejs/router/commit/476c6697)

#####    🐞 Bug Fixes

- Avoid non matchable routes in auto-routes ��-  by [@&#8203;posva](https://github.com/posva) [<samp>(48649)</samp>](https://github.com/vuejs/router/commit/48649030)
- Handle quotes in d.ts  -  by [@&#8203;posva](https://github.com/posva) [<samp>(d7764)</samp>](https://github.com/vuejs/router/commit/d7764700)
- Avoid route entry in map for \_parent  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1dfcc)</samp>](https://github.com/vuejs/router/commit/1dfccf82)
- Handle nested groups  -  by [@&#8203;posva](https://github.com/posva) [<samp>(4a4be)</samp>](https://github.com/vuejs/router/commit/4a4bed94)
- Stable route ordering for group folders with same path  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1db94)</samp>](https://github.com/vuejs/router/commit/1db9467c)
- Correct route ordering for group nodes with inflated scores  -  by [@&#8203;posva](https://github.com/posva) [<samp>(515f4)</samp>](https://github.com/vuejs/router/commit/515f4843)
- Cleanup old route overrides  -  by [@&#8203;posva](https://github.com/posva) [<samp>(b28a7)</samp>](https://github.com/vuejs/router/commit/b28a71e2)
- Remove name from \_parent.vue files  -  by [@&#8203;posva](https://github.com/posva) [<samp>(6e8f1)</samp>](https://github.com/vuejs/router/commit/6e8f1a11)
- **ci**:
  - Format sponsor files before change detection  -  by [@&#8203;posva](https://github.com/posva) [<samp>(f68d6)</samp>](https://github.com/vuejs/router/commit/f68d6fad)
  - Use manual git commit in update-sponsors  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8ee99)</samp>](https://github.com/vuejs/router/commit/8ee992cb)
- **experimental**:
  - Resolve TS errors in resolver/router type hierarchy  -  by [@&#8203;posva](https://github.com/posva) [<samp>(a86f1)</samp>](https://github.com/vuejs/router/commit/a86f1f3a)
- **types**:
  - Relax RouteMapGeneric constraint for interface-based RouteNamedMap  -  by [@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo) in [#&#8203;2624](https://github.com/vuejs/router/issues/2624) [<samp>(cdf7b)</samp>](https://github.com/vuejs/router/commit/cdf7b442)
- **volar**:
  - Use `ts.getTokenPosOfNode` instead of `node.getStart`  -  by [@&#8203;KazariEX](https://github.com/KazariEX) in [#&#8203;2630](https://github.com/vuejs/router/issues/2630) [<samp>(0b050)</samp>](https://github.com/vuejs/router/commit/0b0504bd)

#####    🏎 Performance

- Avoid merging empty object in record  -  by [@&#8203;posva](https://github.com/posva) [<samp>(4213e)</samp>](https://github.com/vuejs/router/commit/4213eb66)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.2...v5.0.3)

### [`v5.0.2`](https://github.com/vuejs/router/releases/tag/v5.0.2)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.1...v5.0.2)

#####    🐞 Bug Fixes

- Remove devtools from iife build  -  by [@&#8203;posva](https://github.com/posva) [<samp>(58c03)</samp>](https://github.com/vuejs/router/commit/58c033c0)
- Loose version check vue-router  -  by [@&#8203;posva](https://github.com/posva) [<samp>(90e4b)</samp>](https://github.com/vuejs/router/commit/90e4bb80)
- **volar**: Empty options  -  by [@&#8203;posva](https://github.com/posva) [<samp>(02275)</samp>](https://github.com/vuejs/router/commit/022758a7)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.1...v5.0.2)

### [`v5.0.1`](https://github.com/vuejs/router/releases/tag/v5.0.1)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.0...v5.0.1)

#####    🐞 Bug Fixes

- **volar**: Make typed plugin work with vue-tsc  -  by [@&#8203;peter50216](https://github.com/peter50216) in [#&#8203;2607](https://github.com/vuejs/router/issues/2607) [<samp>(7845e)</samp>](https://github.com/vuejs/router/commit/7845e327)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.0...v5.0.1)

### [`v5.0.0`](https://github.com/vuejs/router/releases/tag/v5.0.0)

[Compare Source](https://github.com/vuejs/router/compare/v4.6.4...v5.0.0)

Vue Router 5 is a *boring* release, it merges [unplugin-vue-router](https://uvr.esm.is) into the core package with no breaking changes. The only exception is that the *iife* build no longer includes `@vue/devtools-api` because it has been upgraded to v8 and does not expose an IIFE build itself. You can track that change in [this issue](https://github.com/vuejs/devtools/issues/989). See [the migration guide](https://router.vuejs.org/guide/migration/v4-to-v5.html) for instructions on how to upgrade from unplugin-vue-router to Vue Router 5.

#####    🚀 Features

- **experimental**: Query params are optional by default  -  by [@&#8203;posva](https://github.com/posva) [<samp>(78913)</samp>](https://github.com/vuejs/router/commit/78913551)
- Add volar plugins  -  by [@&#8203;posva](https://github.com/posva) [<samp>(530ac)</samp>](https://github.com/vuejs/router/commit/530ac53e)
- Add data loaders as experimental  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ab895)</samp>](https://github.com/vuejs/router/commit/ab89513d)
- Add route json schema  -  by [@&#8203;posva](https://github.com/posva) [<samp>(20675)</samp>](https://github.com/vuejs/router/commit/2067515a)
- Upgrade devtools-api to v7  -  by [@&#8203;posva](https://github.com/posva) [<samp>(17b84)</samp>](https://github.com/vuejs/router/commit/17b841b8)
- Upgrade devtools to v8  -  by [@&#8203;posva](https://github.com/posva) [<samp>(b8aa2)</samp>](https://github.com/vuejs/router/commit/b8aa2395)
- Runtime error on missing param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(3444b)</samp>](https://github.com/vuejs/router/commit/3444bc94)
- **volar**: Allow rootDir option  -  by [@&#8203;posva](https://github.com/posva) [<samp>(df65a)</samp>](https://github.com/vuejs/router/commit/df65a864)

#####    🐞 Bug Fixes

- Avoid breaking older browsers support  -  by [@&#8203;posva](https://github.com/posva) [<samp>(c7ba4)</samp>](https://github.com/vuejs/router/commit/c7ba4507)
- Trigger navigation guards when keep-alive component is reactivated for different route  -  by [@&#8203;babu-ch](https://github.com/babu-ch) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2604](https://github.com/vuejs/router/issues/2604) [<samp>(c7735)</samp>](https://github.com/vuejs/router/commit/c7735d30)
- Add automatic types for param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(0fb5d)</samp>](https://github.com/vuejs/router/commit/0fb5da34)
- Param parsers when dts is not at root  -  by [@&#8203;posva](https://github.com/posva) [<samp>(16b39)</samp>](https://github.com/vuejs/router/commit/16b39ff7)
- Expose resolveOptions for unplugin  -  by [@&#8203;posva](https://github.com/posva) [<samp>(35543)</samp>](https://github.com/vuejs/router/commit/355431b6)
- Escape tildes in paths  -  by [@&#8203;posva](https://github.com/posva) [<samp>(aac2e)</samp>](https://github.com/vuejs/router/commit/aac2e265)
- **volar**: Upgrade config read  -  by [@&#8203;posva](https://github.com/posva) [<samp>(e3024)</samp>](https://github.com/vuejs/router/commit/e3024d19)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v4.6.4...v5.0.0)

</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/12
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-23 03:44:55 +00:00
CI 7398bb0a16 release v1.0.2 [skip ci] 2026-02-22 20:44:40 +00:00
renovate-bot 79d36df83c chore(deps): update dependency eslint to v10 (#10)
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`](https://github.com/eslint/eslint/commit/c87d5bded54c5cf491eb04c24c9d09bbbd42c23e) fix: update eslint ([#&#8203;20531](https://github.com/eslint/eslint/issues/20531)) (renovate\[bot])
- [`d841001`](https://github.com/eslint/eslint/commit/d84100115c14691691058f00779c94e74fca946a) fix: update `minimatch` to `10.2.1` to address security vulnerabilities ([#&#8203;20519](https://github.com/eslint/eslint/issues/20519)) (루밀LuMir)
- [`04c2147`](https://github.com/eslint/eslint/commit/04c21475b3004904948f02049f2888b401d82c78) fix: update error message for unused suppressions ([#&#8203;20496](https://github.com/eslint/eslint/issues/20496)) (fnx)
- [`38b089c`](https://github.com/eslint/eslint/commit/38b089c1726feac0e31a31d47941bd99e29ce003) 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`](https://github.com/eslint/eslint/commit/5b3dbce50a1404a9f118afe810cefeee79388a2a) docs: add AI acknowledgement section to templates ([#&#8203;20431](https://github.com/eslint/eslint/issues/20431)) (루밀LuMir)
- [`6f23076`](https://github.com/eslint/eslint/commit/6f23076037d5879f20fb3be2ef094293b1e8d38c) docs: toggle nav in no-JS mode ([#&#8203;20476](https://github.com/eslint/eslint/issues/20476)) (Tanuj Kanti)
- [`b69cfb3`](https://github.com/eslint/eslint/commit/b69cfb32a16c5d5e9986390d484fae1d21e406f9) docs: Update README (GitHub Actions Bot)

#### Chores

- [`e5c281f`](https://github.com/eslint/eslint/commit/e5c281ffd038a3a7a3e5364db0b9378e0ad83020) chore: updates for v9.39.3 release (Jenkins)
- [`8c3832a`](https://github.com/eslint/eslint/commit/8c3832adb77cd993b4a24891900d5eeaaf093cdc) 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`](https://github.com/eslint/eslint/commit/8330d238ae6adb68bb6a1c9381e38cfedd990d94) test: add tests for config-api ([#&#8203;20493](https://github.com/eslint/eslint/issues/20493)) (Milos Djermanovic)
- [`37d6e91`](https://github.com/eslint/eslint/commit/37d6e91e88fa6a2ca6d8726679096acff21ba6cc) chore: remove eslint v10 prereleases from eslint-config-eslint deps ([#&#8203;20494](https://github.com/eslint/eslint/issues/20494)) (Milos Djermanovic)
- [`da7cd0e`](https://github.com/eslint/eslint/commit/da7cd0e79197ad16e17052eef99df141de6dbfb1) refactor: cleanup error message templates ([#&#8203;20479](https://github.com/eslint/eslint/issues/20479)) (Francesco Trotta)
- [`84fb885`](https://github.com/eslint/eslint/commit/84fb885d49ac810e79a9491276b4828b53d913e5) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`1f66734`](https://github.com/eslint/eslint/commit/1f667344b57c4c09b548d94bcfac1f91b6e5c63d) 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`](https://github.com/eslint/eslint/commit/f9e54f43a5e497cdfa179338b431093245cb787b) feat!: estimate rule-tester failure location ([#&#8203;20420](https://github.com/eslint/eslint/issues/20420)) (ST-DDT)
- [`a176319`](https://github.com/eslint/eslint/commit/a176319d8ade1a7d9b2d7fb8f038f55a2662325f) feat!: replace `chalk` with `styleText` and add `color` to `ResultsMeta` ([#&#8203;20227](https://github.com/eslint/eslint/issues/20227)) (루밀LuMir)
- [`c7046e6`](https://github.com/eslint/eslint/commit/c7046e6c1e03c4ca0eee4888a1f2eba4c6454f84) feat!: enable JSX reference tracking ([#&#8203;20152](https://github.com/eslint/eslint/issues/20152)) (Pixel998)
- [`fa31a60`](https://github.com/eslint/eslint/commit/fa31a608901684fbcd9906d1907e66561d16e5aa) feat!: add `name` to configs ([#&#8203;20015](https://github.com/eslint/eslint/issues/20015)) (Kirk Waiblinger)
- [`3383e7e`](https://github.com/eslint/eslint/commit/3383e7ec9028166cafc8ea7986c2f7498d0049f0) fix!: remove deprecated `SourceCode` methods ([#&#8203;20137](https://github.com/eslint/eslint/issues/20137)) (Pixel998)
- [`501abd0`](https://github.com/eslint/eslint/commit/501abd0e916a35554c58b7c0365537f1fa3880ce) feat!: update dependency minimatch to v10 ([#&#8203;20246](https://github.com/eslint/eslint/issues/20246)) (renovate\[bot])
- [`ca4d3b4`](https://github.com/eslint/eslint/commit/ca4d3b40085de47561f89656a2207d09946ed45e) fix!: stricter rule tester assertions for valid test cases ([#&#8203;20125](https://github.com/eslint/eslint/issues/20125)) (唯然)
- [`96512a6`](https://github.com/eslint/eslint/commit/96512a66c86402fb0538cdcb6cd30b9073f6bf3b) fix!: Remove deprecated rule context methods ([#&#8203;20086](https://github.com/eslint/eslint/issues/20086)) (Nicholas C. Zakas)
- [`c69fdac`](https://github.com/eslint/eslint/commit/c69fdacdb2e886b9d965568a397aa8220db3fe90) feat!: remove eslintrc support ([#&#8203;20037](https://github.com/eslint/eslint/issues/20037)) (Francesco Trotta)
- [`208b5cc`](https://github.com/eslint/eslint/commit/208b5cc34a8374ff81412b5bec2e0800eebfbd04) feat!: Use `ScopeManager#addGlobals()` ([#&#8203;20132](https://github.com/eslint/eslint/issues/20132)) (Milos Djermanovic)
- [`a2ee188`](https://github.com/eslint/eslint/commit/a2ee188ea7a38a0c6155f3d39e2b00e1d0f36e14) fix!: add `uniqueItems: true` in `no-invalid-regexp` option ([#&#8203;20155](https://github.com/eslint/eslint/issues/20155)) (Tanuj Kanti)
- [`a89059d`](https://github.com/eslint/eslint/commit/a89059dbf2832d417dd493ee81483227ec44e4ab) feat!: Program range span entire source text ([#&#8203;20133](https://github.com/eslint/eslint/issues/20133)) (Pixel998)
- [`39a6424`](https://github.com/eslint/eslint/commit/39a6424373d915fa9de0d7b0caba9a4dc3da9b53) fix!: assert 'text' is a string across all RuleFixer methods ([#&#8203;20082](https://github.com/eslint/eslint/issues/20082)) (Pixel998)
- [`f28fbf8`](https://github.com/eslint/eslint/commit/f28fbf846244e043c92b355b224d121b06140b44) fix!: Deprecate `"always"` and `"as-needed"` options of the `radix` rule ([#&#8203;20223](https://github.com/eslint/eslint/issues/20223)) (Milos Djermanovic)
- [`aa3fb2b`](https://github.com/eslint/eslint/commit/aa3fb2b233e929b37220be940575f42c280e0b98) fix!: tighten `func-names` schema ([#&#8203;20119](https://github.com/eslint/eslint/issues/20119)) (Pixel998)
- [`f6c0ed0`](https://github.com/eslint/eslint/commit/f6c0ed0311dcfee853367d5068c765d066e6b756) feat!: report `eslint-env` comments as errors ([#&#8203;20128](https://github.com/eslint/eslint/issues/20128)) (Francesco Trotta)
- [`4bf739f`](https://github.com/eslint/eslint/commit/4bf739fb533e59f7f0a66b65f7bc80be0f37d8db) fix!: remove deprecated `LintMessage#nodeType` and `TestCaseError#type` ([#&#8203;20096](https://github.com/eslint/eslint/issues/20096)) (Pixel998)
- [`523c076`](https://github.com/eslint/eslint/commit/523c076866400670fb2192a3f55dbf7ad3469247) feat!: drop support for jiti < 2.2.0 ([#&#8203;20016](https://github.com/eslint/eslint/issues/20016)) (michael faith)
- [`454a292`](https://github.com/eslint/eslint/commit/454a292c95f34dad232411ddac06408e6383bb64) feat!: update `eslint:recommended` configuration ([#&#8203;20210](https://github.com/eslint/eslint/issues/20210)) (Pixel998)
- [`4f880ee`](https://github.com/eslint/eslint/commit/4f880ee02992e1bf0e96ebaba679985e2d1295f1) feat!: remove `v10_*` and inactive `unstable_*` flags ([#&#8203;20225](https://github.com/eslint/eslint/issues/20225)) (sethamus)
- [`f18115c`](https://github.com/eslint/eslint/commit/f18115c363a4ac7671a4c7f30ee13d57ebba330f) feat!: `no-shadow-restricted-names` report `globalThis` by default ([#&#8203;20027](https://github.com/eslint/eslint/issues/20027)) (sethamus)
- [`c6358c3`](https://github.com/eslint/eslint/commit/c6358c31fbd3937b92d89be2618ffdf5a774604e) feat!: Require Node.js `^20.19.0 || ^22.13.0 || >=24` ([#&#8203;20160](https://github.com/eslint/eslint/issues/20160)) (Milos Djermanovic)

#### Features

- [`bff9091`](https://github.com/eslint/eslint/commit/bff9091927811497dbf066b0e3b85ecb37d43822) feat: handle `Array.fromAsync` in `array-callback-return` ([#&#8203;20457](https://github.com/eslint/eslint/issues/20457)) (Francesco Trotta)
- [`290c594`](https://github.com/eslint/eslint/commit/290c594bb50c439fb71bc75521ee5360daa8c222) feat: add `self` to `no-implied-eval` rule ([#&#8203;20468](https://github.com/eslint/eslint/issues/20468)) (sethamus)
- [`43677de`](https://github.com/eslint/eslint/commit/43677de07ebd6e14bfac40a46ad749ba783c45f2) feat: fix handling of function and class expression names in `no-shadow` ([#&#8203;20432](https://github.com/eslint/eslint/issues/20432)) (Milos Djermanovic)
- [`f0cafe5`](https://github.com/eslint/eslint/commit/f0cafe5f37e7765e9d8c2751b5f5d33107687009) feat: rule tester add assertion option `requireData` ([#&#8203;20409](https://github.com/eslint/eslint/issues/20409)) (fnx)
- [`f7ab693`](https://github.com/eslint/eslint/commit/f7ab6937e63bc618d326710858f5861a68f80616) feat: output RuleTester test case failure index ([#&#8203;19976](https://github.com/eslint/eslint/issues/19976)) (ST-DDT)
- [`7cbcbf9`](https://github.com/eslint/eslint/commit/7cbcbf9c3c2008deee7d143ae35e668e8ffbccb3) feat: add `countThis` option to `max-params` ([#&#8203;20236](https://github.com/eslint/eslint/issues/20236)) (Gerkin)
- [`f148a5e`](https://github.com/eslint/eslint/commit/f148a5eaa1e89dd80ade62f0a690186b00b9f6e1) feat: add error assertion options ([#&#8203;20247](https://github.com/eslint/eslint/issues/20247)) (ST-DDT)
- [`09e6654`](https://github.com/eslint/eslint/commit/09e66549ecada6dcb8c567a60faf044fce049188) 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`](https://github.com/eslint/eslint/commit/436b82f3c0a8cfa2fdc17d173e95ea11d5d3ee03) fix: update eslint ([#&#8203;20473](https://github.com/eslint/eslint/issues/20473)) (renovate\[bot])
- [`1d29d22`](https://github.com/eslint/eslint/commit/1d29d22fe302443cec2a11da0816397f94af97ec) fix: detect default `this` binding in `Array.fromAsync` callbacks ([#&#8203;20456](https://github.com/eslint/eslint/issues/20456)) (Francesco Trotta)
- [`727451e`](https://github.com/eslint/eslint/commit/727451eff55b35d853e0e443d0de58f4550762bf) fix: fix regression of global mode report range in `strict` rule ([#&#8203;20462](https://github.com/eslint/eslint/issues/20462)) (ntnyq)
- [`e80485f`](https://github.com/eslint/eslint/commit/e80485fcd27196fa0b6f6b5c7ac8cf49ad4b079d) fix: remove fake `FlatESLint` and `LegacyESLint` exports ([#&#8203;20460](https://github.com/eslint/eslint/issues/20460)) (Francesco Trotta)
- [`9eeff3b`](https://github.com/eslint/eslint/commit/9eeff3bc13813a786b8a4c3815def97c0fb646ef) fix: update esquery ([#&#8203;20423](https://github.com/eslint/eslint/issues/20423)) (cryptnix)
- [`b34b938`](https://github.com/eslint/eslint/commit/b34b93852d014ebbcf3538d892b55e0216cdf681) fix: use `Error.prepareStackTrace` to estimate failing test location ([#&#8203;20436](https://github.com/eslint/eslint/issues/20436)) (Francesco Trotta)
- [`51aab53`](https://github.com/eslint/eslint/commit/51aab5393b058f7cbed69041a9069b2bd106aabd) fix: update eslint ([#&#8203;20443](https://github.com/eslint/eslint/issues/20443)) (renovate\[bot])
- [`23490b2`](https://github.com/eslint/eslint/commit/23490b266276792896a0b7b43c49a1ce87bf8568) fix: handle space before colon in `RuleTester` location estimation ([#&#8203;20433](https://github.com/eslint/eslint/issues/20433)) (Francesco Trotta)
- [`f244dbf`](https://github.com/eslint/eslint/commit/f244dbf2191267a4cafd08645243624baf3e8c83) fix: use `MessagePlaceholderData` type from `@eslint/core` ([#&#8203;20348](https://github.com/eslint/eslint/issues/20348)) (루밀LuMir)
- [`d186f8c`](https://github.com/eslint/eslint/commit/d186f8c0747f14890e86a5a39708b052b391ddaf) fix: update eslint ([#&#8203;20427](https://github.com/eslint/eslint/issues/20427)) (renovate\[bot])
- [`2332262`](https://github.com/eslint/eslint/commit/2332262deb4ef3188b210595896bb0ff552a7e66) fix: error location should not modify error message in RuleTester ([#&#8203;20421](https://github.com/eslint/eslint/issues/20421)) (Milos Djermanovic)
- [`ab99b21`](https://github.com/eslint/eslint/commit/ab99b21a6715dee1035d8f4e6d6841853eb5563f) fix: ensure `filename` is passed as third argument to `verifyAndFix()` ([#&#8203;20405](https://github.com/eslint/eslint/issues/20405)) (루밀LuMir)
- [`8a60f3b`](https://github.com/eslint/eslint/commit/8a60f3bc80ad96c65feeb29886342623c630199c) fix: remove `ecmaVersion` and `sourceType` from `ParserOptions` type ([#&#8203;20415](https://github.com/eslint/eslint/issues/20415)) (Pixel998)
- [`eafd727`](https://github.com/eslint/eslint/commit/eafd727a060131f7fc79b2eb5698d8d27683c3a2) fix: remove `TDZ` scope type ([#&#8203;20231](https://github.com/eslint/eslint/issues/20231)) (jaymarvelz)
- [`39d1f51`](https://github.com/eslint/eslint/commit/39d1f51680d4fbade16b4d9c07ad61a87ee3b1ea) fix: correct `Scope` typings ([#&#8203;20404](https://github.com/eslint/eslint/issues/20404)) (sethamus)
- [`2bd0f13`](https://github.com/eslint/eslint/commit/2bd0f13a92fb373827f16210aa4748d4885fddb1) fix: update `verify` and `verifyAndFix` types ([#&#8203;20384](https://github.com/eslint/eslint/issues/20384)) (Francesco Trotta)
- [`ba6ebfa`](https://github.com/eslint/eslint/commit/ba6ebfa78de0b8522cea5ee80179887e92c6c935) fix: correct typings for `loadESLint()` and `shouldUseFlatConfig()` ([#&#8203;20393](https://github.com/eslint/eslint/issues/20393)) (루밀LuMir)
- [`e7673ae`](https://github.com/eslint/eslint/commit/e7673ae096900330599680efe91f8a199a5c2e59) fix: correct RuleTester typings ([#&#8203;20105](https://github.com/eslint/eslint/issues/20105)) (Pixel998)
- [`53e9522`](https://github.com/eslint/eslint/commit/53e95222af8561a8eed282fa9fd44b2f320a3c37) fix: strict removed formatters check ([#&#8203;20241](https://github.com/eslint/eslint/issues/20241)) (ntnyq)
- [`b017f09`](https://github.com/eslint/eslint/commit/b017f094d4e53728f8d335b9cf8b16dc074afda3) fix: correct `no-restricted-import` messages ([#&#8203;20374](https://github.com/eslint/eslint/issues/20374)) (Francesco Trotta)

#### Documentation

- [`e978dda`](https://github.com/eslint/eslint/commit/e978ddaab7e6a3c38b4a2afa721148a6ef38f29a) docs: Update README (GitHub Actions Bot)
- [`4cecf83`](https://github.com/eslint/eslint/commit/4cecf8393ae9af18c4cfd50621115eb23b3d0cb6) docs: Update README (GitHub Actions Bot)
- [`c79f0ab`](https://github.com/eslint/eslint/commit/c79f0ab2e2d242a93b08ff2f6a0712e2ef60b7b8) docs: Update README (GitHub Actions Bot)
- [`773c052`](https://github.com/eslint/eslint/commit/773c0527c72c09fb5e63c2036b5cb9783f1f04d3) docs: Update README (GitHub Actions Bot)
- [`f2962e4`](https://github.com/eslint/eslint/commit/f2962e46a0e8ee8e04d76e9d899f6a7c73a646f1) docs: document `meta.docs.frozen` property ([#&#8203;20475](https://github.com/eslint/eslint/issues/20475)) (Pixel998)
- [`8e94f58`](https://github.com/eslint/eslint/commit/8e94f58bebfd854eed814a39e19dea4e3c3ee4a3) docs: fix broken anchor links from gerund heading updates ([#&#8203;20449](https://github.com/eslint/eslint/issues/20449)) (Copilot)
- [`1495654`](https://github.com/eslint/eslint/commit/14956543d42ab542f72820f38941d0bcc39a1fbb) docs: Update README (GitHub Actions Bot)
- [`0b8ed5c`](https://github.com/eslint/eslint/commit/0b8ed5c0aa4222a9b6b185c605cfedaef4662dcb) docs: document support for `:is` selector alias ([#&#8203;20454](https://github.com/eslint/eslint/issues/20454)) (sethamus)
- [`1c4b33f`](https://github.com/eslint/eslint/commit/1c4b33fe8620dcaafbe6e8f4e9515b624476548c) docs: Document policies about ESM-only dependencies ([#&#8203;20448](https://github.com/eslint/eslint/issues/20448)) (Milos Djermanovic)
- [`3e5d38c`](https://github.com/eslint/eslint/commit/3e5d38cdd5712bef50d440585b0f6669a2e9a9b9) docs: add missing indentation space in rule example ([#&#8203;20446](https://github.com/eslint/eslint/issues/20446)) (fnx)
- [`63a0c7c`](https://github.com/eslint/eslint/commit/63a0c7c84bf5b12357893ea2bf0482aa3c855bac) docs: Update README (GitHub Actions Bot)
- [`65ed0c9`](https://github.com/eslint/eslint/commit/65ed0c94e7cd1e3f882956113228311d8c7b3463) docs: Update README (GitHub Actions Bot)
- [`b0e4717`](https://github.com/eslint/eslint/commit/b0e4717d6619ffd02913cf3633b44d8e6953d938) docs: \[no-await-in-loop] Expand inapplicability ([#&#8203;20363](https://github.com/eslint/eslint/issues/20363)) (Niklas Hambüchen)
- [`fca421f`](https://github.com/eslint/eslint/commit/fca421f6a4eecd52f2a7ae5765bd9008f62f9994) docs: Update README (GitHub Actions Bot)
- [`d925c54`](https://github.com/eslint/eslint/commit/d925c54f045b2230d3404e8aa18f4e2860a35e1d) docs: update config syntax in `no-lone-blocks` ([#&#8203;20413](https://github.com/eslint/eslint/issues/20413)) (Pixel998)
- [`7d5c95f`](https://github.com/eslint/eslint/commit/7d5c95f281cb88868f4e09ca07fbbc6394d78c41) docs: remove redundant `sourceType: "module"` from rule examples ([#&#8203;20412](https://github.com/eslint/eslint/issues/20412)) (Pixel998)
- [`02e7e71`](https://github.com/eslint/eslint/commit/02e7e7126366fc5eeffb713f865d80a759dc14b0) docs: correct `.mts` glob pattern in files with extensions example ([#&#8203;20403](https://github.com/eslint/eslint/issues/20403)) (Ali Essalihi)
- [`264b981`](https://github.com/eslint/eslint/commit/264b981101a3cf0c12eba200ac64e5523186a89f) docs: Update README (GitHub Actions Bot)
- [`5a4324f`](https://github.com/eslint/eslint/commit/5a4324f38e7ce370038351ef7412dcf8548c105e) docs: clarify `"local"` option of `no-unused-vars` ([#&#8203;20385](https://github.com/eslint/eslint/issues/20385)) (Milos Djermanovic)
- [`e593aa0`](https://github.com/eslint/eslint/commit/e593aa0fd29f51edea787815ffc847aa723ef1f8) docs: improve clarity, grammar, and wording in documentation site README ([#&#8203;20370](https://github.com/eslint/eslint/issues/20370)) (Aditya)
- [`3f5062e`](https://github.com/eslint/eslint/commit/3f5062ed5f27eb25414faced2478ae076906874e) docs: Add messages property to rule meta documentation ([#&#8203;20361](https://github.com/eslint/eslint/issues/20361)) (Sabya Sachi)
- [`9e5a5c2`](https://github.com/eslint/eslint/commit/9e5a5c2b6b368cdacd678eabf36b441bd8bb726c) docs: remove `Examples` headings from rule docs ([#&#8203;20364](https://github.com/eslint/eslint/issues/20364)) (Milos Djermanovic)
- [`194f488`](https://github.com/eslint/eslint/commit/194f488a8dc97850485afe704d2a64096582f96d) docs: Update README (GitHub Actions Bot)
- [`0f5a94a`](https://github.com/eslint/eslint/commit/0f5a94a84beee19f376025c74f703f275d52c94b) docs: \[class-methods-use-this] explain purpose of rule ([#&#8203;20008](https://github.com/eslint/eslint/issues/20008)) (Kirk Waiblinger)
- [`df5566f`](https://github.com/eslint/eslint/commit/df5566f826d9f5740546e473aa6876b1f7d2f12c) docs: add Options section to all rule docs ([#&#8203;20296](https://github.com/eslint/eslint/issues/20296)) (sethamus)
- [`adf7a2b`](https://github.com/eslint/eslint/commit/adf7a2b202743a98edc454890574292dd2b34837) docs: no-unsafe-finally note for generator functions ([#&#8203;20330](https://github.com/eslint/eslint/issues/20330)) (Tom Pereira)
- [`ef7028c`](https://github.com/eslint/eslint/commit/ef7028c9688dc931051a4217637eb971efcbd71b) docs: Update README (GitHub Actions Bot)
- [`fbae5d1`](https://github.com/eslint/eslint/commit/fbae5d18854b30ea3b696672c7699cef3ec92140) docs: consistently use "v10.0.0" in migration guide ([#&#8203;20328](https://github.com/eslint/eslint/issues/20328)) (Pixel998)
- [`778aa2d`](https://github.com/eslint/eslint/commit/778aa2d83e1ef1e2bd1577ee976c5a43472a3dbe) docs: ignoring default file patterns ([#&#8203;20312](https://github.com/eslint/eslint/issues/20312)) (Tanuj Kanti)
- [`4b5dbcd`](https://github.com/eslint/eslint/commit/4b5dbcdae52c1c16293dc68028cab18ed2504841) docs: reorder v10 migration guide ([#&#8203;20315](https://github.com/eslint/eslint/issues/20315)) (Milos Djermanovic)
- [`5d84a73`](https://github.com/eslint/eslint/commit/5d84a7371d01ead1b274600c055fe49150d487f1) docs: Update README (GitHub Actions Bot)
- [`37c8863`](https://github.com/eslint/eslint/commit/37c8863088a2d7e845d019f68a329f53a3fe2c35) docs: fix incorrect anchor link in v10 migration guide ([#&#8203;20299](https://github.com/eslint/eslint/issues/20299)) (Pixel998)
- [`077ff02`](https://github.com/eslint/eslint/commit/077ff028b6ce036da091d2f7ed8c606c9d017468) docs: add migrate-to-10.0.0 doc ([#&#8203;20143](https://github.com/eslint/eslint/issues/20143)) (唯然)
- [`3822e1b`](https://github.com/eslint/eslint/commit/3822e1b768bb4a64b72b73b5657737a6ee5c8afe) docs: Update README (GitHub Actions Bot)

#### Build Related

- [`9f08712`](https://github.com/eslint/eslint/commit/9f0871236e90ec78bcdbfa352cc1363b4bae5596) Build: changelog update for 10.0.0-rc.2 (Jenkins)
- [`1e2c449`](https://github.com/eslint/eslint/commit/1e2c449701524b426022fde19144b1d22d8197b0) Build: changelog update for 10.0.0-rc.1 (Jenkins)
- [`c4c72a8`](https://github.com/eslint/eslint/commit/c4c72a8d996dda629e85e78a6ef5417242594b5d) Build: changelog update for 10.0.0-rc.0 (Jenkins)
- [`7e4daf9`](https://github.com/eslint/eslint/commit/7e4daf93d255ed343d68e999aad167bb20e5a96b) Build: changelog update for 10.0.0-beta.0 (Jenkins)
- [`a126a2a`](https://github.com/eslint/eslint/commit/a126a2ab136406017f2dac2d7632114e37e62dc2) build: add .scss files entry to knip ([#&#8203;20389](https://github.com/eslint/eslint/issues/20389)) (Francesco Trotta)
- [`f5c0193`](https://github.com/eslint/eslint/commit/f5c01932f69189b260646d60b28011c55870e65d) Build: changelog update for 10.0.0-alpha.1 (Jenkins)
- [`165326f`](https://github.com/eslint/eslint/commit/165326f0469dd6a9b33598a6fceb66336bb2deb5) Build: changelog update for 10.0.0-alpha.0 (Jenkins)

#### Chores

- [`1ece282`](https://github.com/eslint/eslint/commit/1ece282c2286b5dc187ece2a793dbd8798f20bd7) chore: ignore `/docs/v9.x` in link checker ([#&#8203;20452](https://github.com/eslint/eslint/issues/20452)) (Milos Djermanovic)
- [`034e139`](https://github.com/eslint/eslint/commit/034e1397446205e83eb341354605380195c88633) ci: add type integration test for `@html-eslint/eslint-plugin` ([#&#8203;20345](https://github.com/eslint/eslint/issues/20345)) (sethamus)
- [`f3fbc2f`](https://github.com/eslint/eslint/commit/f3fbc2f60cbe2c718364feb8c3fc0452c0df3c56) 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`](https://github.com/eslint/eslint/commit/afc06817bbd0625c7b0a46bdc81c38dab0c99441) chore: remove scopeManager.addGlobals patch for typescript-eslint parser ([#&#8203;20461](https://github.com/eslint/eslint/issues/20461)) (fnx)
- [`3e5a173`](https://github.com/eslint/eslint/commit/3e5a173053fe0bb3d0f29aff12eb2c19ae21aa36) refactor: use types from `@eslint/plugin-kit` ([#&#8203;20435](https://github.com/eslint/eslint/issues/20435)) (Pixel998)
- [`11644b1`](https://github.com/eslint/eslint/commit/11644b1dc2bdf4c4f3a97901932e5f25c9f60775) ci: rename workflows ([#&#8203;20463](https://github.com/eslint/eslint/issues/20463)) (Milos Djermanovic)
- [`2d14173`](https://github.com/eslint/eslint/commit/2d14173729ae75fe562430dd5e37c457f44bc7ac) chore: fix typos in docs and comments ([#&#8203;20458](https://github.com/eslint/eslint/issues/20458)) (o-m12a)
- [`6742f92`](https://github.com/eslint/eslint/commit/6742f927ba6afb1bce6f64b9b072a1a11dbf53c4) test: add endLine/endColumn to invalid test case in no-alert ([#&#8203;20441](https://github.com/eslint/eslint/issues/20441)) (경하)
- [`3e22c82`](https://github.com/eslint/eslint/commit/3e22c82a87f44f7407ff75b17b26f1ceed3edd14) test: add missing location data to no-template-curly-in-string tests ([#&#8203;20440](https://github.com/eslint/eslint/issues/20440)) (Haeun Kim)
- [`b4b3127`](https://github.com/eslint/eslint/commit/b4b3127f8542c599ce2dea804b6582ebc40c993d) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`f658419`](https://github.com/eslint/eslint/commit/f6584191cb5cabd62f6a197339a91e1f9b3f8432) refactor: remove `raw` parser option from JS language ([#&#8203;20416](https://github.com/eslint/eslint/issues/20416)) (Pixel998)
- [`2c3efb7`](https://github.com/eslint/eslint/commit/2c3efb728b294b74a240ec24c7be8137a31cf5f0) chore: remove `category` from type test fixtures ([#&#8203;20417](https://github.com/eslint/eslint/issues/20417)) (Pixel998)
- [`36193fd`](https://github.com/eslint/eslint/commit/36193fd9ad27764d8e4a24ce7c7bbeeaf5d4a6ba) chore: remove `category` from formatter test fixtures ([#&#8203;20418](https://github.com/eslint/eslint/issues/20418)) (Pixel998)
- [`e8d203b`](https://github.com/eslint/eslint/commit/e8d203b0d9f66e55841863f90d215fd83b7eee0f) chore: add JSX language tag validation to `check-rule-examples` ([#&#8203;20414](https://github.com/eslint/eslint/issues/20414)) (Pixel998)
- [`bc465a1`](https://github.com/eslint/eslint/commit/bc465a1e9d955b6e53a45d1b5da7c632dae77262) chore: pin dependencies ([#&#8203;20397](https://github.com/eslint/eslint/issues/20397)) (renovate\[bot])
- [`703f0f5`](https://github.com/eslint/eslint/commit/703f0f551daea28767e5a68a00e335928919a7ff) test: replace deprecated rules in `linter` tests ([#&#8203;20406](https://github.com/eslint/eslint/issues/20406)) (루밀LuMir)
- [`ba71baa`](https://github.com/eslint/eslint/commit/ba71baa87265888b582f314163df1d727441e2f1) test: enable `strict` mode in type tests ([#&#8203;20398](https://github.com/eslint/eslint/issues/20398)) (루밀LuMir)
- [`f9c4968`](https://github.com/eslint/eslint/commit/f9c49683a6d69ff0b5425803955fc226f7e05d76) refactor: remove `lib/linter/rules.js` ([#&#8203;20399](https://github.com/eslint/eslint/issues/20399)) (Francesco Trotta)
- [`6f1c48e`](https://github.com/eslint/eslint/commit/6f1c48e5e7f8195f7796ea04e756841391ada927) chore: updates for v9.39.2 release (Jenkins)
- [`54bf0a3`](https://github.com/eslint/eslint/commit/54bf0a3646265060f5f22faef71ec840d630c701) ci: create package manager test ([#&#8203;20392](https://github.com/eslint/eslint/issues/20392)) (루밀LuMir)
- [`3115021`](https://github.com/eslint/eslint/commit/3115021439490d1ed12da5804902ebbf8a5e574b) refactor: simplify JSDoc comment detection logic ([#&#8203;20360](https://github.com/eslint/eslint/issues/20360)) (Pixel998)
- [`4345b17`](https://github.com/eslint/eslint/commit/4345b172a81e1394579ec09df51ba460b956c3b5) chore: update `@eslint-community/regexpp` to `4.12.2` ([#&#8203;20366](https://github.com/eslint/eslint/issues/20366)) (루밀LuMir)
- [`772c9ee`](https://github.com/eslint/eslint/commit/772c9ee9b65b6ad0be3e46462a7f93c37578cfa8) 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`](https://github.com/eslint/eslint/commit/0b14059491d830a49b3577931f4f68fbcfce6be5) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`d6e7bf3`](https://github.com/eslint/eslint/commit/d6e7bf3064be01d159d6856e3718672c6a97a8e1) ci: bump actions/checkout from 5 to 6 ([#&#8203;20350](https://github.com/eslint/eslint/issues/20350)) (dependabot\[bot])
- [`139d456`](https://github.com/eslint/eslint/commit/139d4567d4afe3f1e1cdae21769d5e868f90ef0d) chore: require mandatory headers in rule docs ([#&#8203;20347](https://github.com/eslint/eslint/issues/20347)) (Milos Djermanovic)
- [`3b0289c`](https://github.com/eslint/eslint/commit/3b0289c7b605b2d94fe2d0c347d07eea4b6ba1d4) chore: remove unused `.eslintignore` and test fixtures ([#&#8203;20316](https://github.com/eslint/eslint/issues/20316)) (Pixel998)
- [`a463e7b`](https://github.com/eslint/eslint/commit/a463e7bea0d18af55e5557e33691e4b0685d9523) chore: update dependency js-yaml to v4 \[security] ([#&#8203;20319](https://github.com/eslint/eslint/issues/20319)) (renovate\[bot])
- [`ebfe905`](https://github.com/eslint/eslint/commit/ebfe90533d07a7020a5c63b93763fe537120f61f) chore: remove redundant rules from eslint-config-eslint ([#&#8203;20327](https://github.com/eslint/eslint/issues/20327)) (Milos Djermanovic)
- [`88dfdb2`](https://github.com/eslint/eslint/commit/88dfdb23ee541de4e9c3aa5d8a152c5980f6cc3f) test: add regression tests for message placeholder interpolation ([#&#8203;20318](https://github.com/eslint/eslint/issues/20318)) (fnx)
- [`6ed0f75`](https://github.com/eslint/eslint/commit/6ed0f758ff460b7a182c8d16b0487ae707e43cc9) chore: skip type checking in `eslint-config-eslint` ([#&#8203;20323](https://github.com/eslint/eslint/issues/20323)) (Francesco Trotta)
- [`1e2cad5`](https://github.com/eslint/eslint/commit/1e2cad5f6fa47ed6ed89d2a29798dda926d50990) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`9da2679`](https://github.com/eslint/eslint/commit/9da26798483270a2c3c490c41cbd8f0c28edf75a) chore: update `@eslint/*` dependencies ([#&#8203;20321](https://github.com/eslint/eslint/issues/20321)) (Milos Djermanovic)
- [`0439794`](https://github.com/eslint/eslint/commit/043979418161e1c17becef31b1dd5c6e1b031e98) refactor: use types from [@&#8203;eslint/core](https://github.com/eslint/core) ([#&#8203;20235](https://github.com/eslint/eslint/issues/20235)) (jaymarvelz)
- [`cb51ec2`](https://github.com/eslint/eslint/commit/cb51ec2d6d3b729bf02a5e6b58b236578c6cce42) test: cleanup `SourceCode#traverse` tests ([#&#8203;20289](https://github.com/eslint/eslint/issues/20289)) (Milos Djermanovic)
- [`897a347`](https://github.com/eslint/eslint/commit/897a3471d6da073c1a179fa84f7a3fe72973ec45) chore: remove restriction for `type` in rule tests ([#&#8203;20305](https://github.com/eslint/eslint/issues/20305)) (Pixel998)
- [`d972098`](https://github.com/eslint/eslint/commit/d9720988579734da7323fbacca4c67058651d6ff) chore: ignore prettier updates in renovate to keep in sync with trunk ([#&#8203;20304](https://github.com/eslint/eslint/issues/20304)) (Pixel998)
- [`a086359`](https://github.com/eslint/eslint/commit/a0863593872fe01b5dd0e04c682450c26ae40ac8) chore: remove redundant `fast-glob` dev-dependency ([#&#8203;20301](https://github.com/eslint/eslint/issues/20301)) (루밀LuMir)
- [`564b302`](https://github.com/eslint/eslint/commit/564b30215c3c1aba47bc29f948f11db5c824cacd) chore: install `prettier` as a dev dependency ([#&#8203;20302](https://github.com/eslint/eslint/issues/20302)) (michael faith)
- [`8257b57`](https://github.com/eslint/eslint/commit/8257b5729d6a26f88b079aa389df4ecea4451a80) refactor: correct regex for `eslint-plugin/report-message-format` ([#&#8203;20300](https://github.com/eslint/eslint/issues/20300)) (루밀LuMir)
- [`e251671`](https://github.com/eslint/eslint/commit/e2516713bc9ae62117da3f490d9cb6a9676f44fe) refactor: extract assertions in RuleTester ([#&#8203;20135](https://github.com/eslint/eslint/issues/20135)) (唯然)
- [`2e7f25e`](https://github.com/eslint/eslint/commit/2e7f25e18908e66d9bd1a4dc016709e39e19a24d) chore: add `legacy-peer-deps` to `.npmrc` ([#&#8203;20281](https://github.com/eslint/eslint/issues/20281)) (Milos Djermanovic)
- [`39c638a`](https://github.com/eslint/eslint/commit/39c638a9aeb7ddc353684d536bbf69d1d39380bd) chore: update eslint-config-eslint dependencies for v10 prereleases ([#&#8203;20278](https://github.com/eslint/eslint/issues/20278)) (Milos Djermanovic)
- [`8533b3f`](https://github.com/eslint/eslint/commit/8533b3fa281e6ecc481083ee83e9c34cae22f31c) 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`](https://github.com/eslint/eslint/commit/796ddf6db5c8fe3e098aa3198128f8ce3c58f8e0) 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`](https://github.com/eslint/eslint/commit/791bf8d7e76ce7ab8c88cb8982658823da4eff27) fix: restore TypeScript 4.0 compatibility in types ([#&#8203;20504](https://github.com/eslint/eslint/issues/20504)) (sethamus)

#### Chores

- [`8594a43`](https://github.com/eslint/eslint/commit/8594a436c22a0167fe3c2c4109bbdb04e519a014) 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`](https://github.com/eslint/eslint/commit/9ceef92fbd3d1298d9a00483f86897834b88acac) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`af498c6`](https://github.com/eslint/eslint/commit/af498c63b9ca065223a425a85afabdcc8451e69b) 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
renovate-bot 7d955cd89f chore: Configure Renovate (#7)
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
keligrubb 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
keligrubb e61e6bc7e3 major: kestrel is now a tak server (#6)
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
166 changed files with 14211 additions and 6079 deletions
+92
View File
@@ -0,0 +1,92 @@
name: PR
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate dev cert
run: ./scripts/gen-dev-cert.sh
- name: Run e2e tests
run: npm run test:e2e
env:
NODE_TLS_REJECT_UNAUTHORIZED: "0"
docker-build:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Set Docker image tag
id: image
run: |
REGISTRY="${GITHUB_SERVER_URL#https://}"
REGISTRY="${REGISTRY#http://}"
echo "tag=${REGISTRY}/${{ github.repository }}:latest" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
- name: Build (dry run)
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
env:
# Keeps GITHUB_OUTPUT small; Gitea act-runner can choke on multiline
# outputs when PR webhook payloads (e.g. Renovate bodies) are huge.
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
push: false
provenance: false
sbom: false
tags: ${{ steps.image.outputs.tag }}
+90
View File
@@ -0,0 +1,90 @@
name: Push
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Get PR description for changelog
env:
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq jq
RESP=$(curl -sf -H "Authorization: token $GITEA_REPO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull") || true
if [ -n "$RESP" ]; then
echo "$RESP" | jq -r '.body // empty' > .ci_pr_body 2>/dev/null || true
fi
- name: Release (bump, tag, push, create release)
env:
CI_REPO_OWNER: ${{ github.actor }}
CI_REPO_NAME: ${{ github.event.repository.name }}
CI_FORGE_URL: ${{ github.server_url }}
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq git wget
./scripts/release.sh
publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
ref: main
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Log in to container registry
uses: https://git.keligrubb.com/actions/docker-login-action@v4
with:
registry: git.keligrubb.com
username: ${{ github.actor }}
password: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Set up Docker Buildx
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
- name: Build Docker image
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
with:
context: .
load: true
tags: kestrelos:built
- name: Push Docker image (version + latest)
run: |
VERSION=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $VERSION)"; exit 1 ;;
esac
REGISTRY="git.keligrubb.com"
IMAGE="$REGISTRY/${{ github.repository }}"
for tag in "$VERSION" latest; do
docker tag kestrelos:built "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done
- name: Set up Helm
uses: https://git.keligrubb.com/actions/setup-helm@v5
- name: Package and push Helm chart
env:
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
helm package helm/kestrelos
for f in kestrelos-*.tgz; do
curl -sf -u "${{ github.actor }}:$GITEA_REPO_TOKEN" -X POST --upload-file "$f" \
"${{ github.server_url }}/api/packages/${{ github.actor }}/helm/api/charts"
done
+1 -1
View File
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.3"
-38
View File
@@ -1,38 +0,0 @@
when:
- event: pull_request
steps:
- name: lint
image: node:24-slim
depends_on: []
commands:
- npm ci
- npm run lint
- name: test
image: node:24-slim
depends_on: []
commands:
- npm ci
- npm run test
- name: e2e
image: mcr.microsoft.com/playwright:v1.58.2-noble
depends_on: []
commands:
- npm ci
- ./scripts/gen-dev-cert.sh
- npm run test:e2e
environment:
NODE_TLS_REJECT_UNAUTHORIZED: "0"
- name: docker-build
image: woodpeckerci/plugin-kaniko
depends_on: []
settings:
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
tags: latest
dry-run: true
single-snapshot: true
cleanup: true
-36
View File
@@ -1,36 +0,0 @@
when:
- event: push
branch: main
steps:
- name: release
image: alpine
commands:
- apk add --no-cache git
- ./scripts/release.sh
environment:
GITEA_REPO_TOKEN:
from_secret: gitea_repo_token
- name: docker
image: woodpeckerci/plugin-kaniko
depends_on: [release]
settings:
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
username: ${CI_REPO_OWNER}
password:
from_secret: gitea_registry_token
single-snapshot: true
cleanup: true
- name: helm
image: alpine/helm
depends_on: [release]
environment:
GITEA_REGISTRY_TOKEN:
from_secret: gitea_registry_token
commands:
- apk add --no-cache curl
- helm package helm/kestrelos
- curl -sf -u $CI_REPO_OWNER:$GITEA_REGISTRY_TOKEN -X POST --upload-file kestrelos-*.tgz https://git.keligrubb.com/api/packages/$CI_REPO_OWNER/helm/api/charts
+593
View File
@@ -1,3 +1,596 @@
## [1.1.6] - 2026-06-24
### Changed
- Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## Summary
- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.
## Docs
- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates
## [1.1.5] - 2026-06-21
### Changed
- update https://git.keligrubb.com/actions/checkout action to v7 (#35)
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6``v7` |
---
### Release Notes
<details>
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>
### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)
### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.4] - 2026-04-29
### Changed
- update all non-major dependencies (#31)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.6/2.4.8?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.7.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.6.0/5.7.2?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.19/3.19.21?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.8/3.19.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.32/3.5.33?slim=true) |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.4/5.0.6?slim=true) |
---
### Release Notes
<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>
### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
#####    🚀 Experimental Features
- **coverage**: Istanbul to support `instrumenter` option  -  by [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)
#####    🐞 Bug Fixes
- \--project negation excludes browser instances  -  by [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
- Project color label on html reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
- Fix `vi.defineHelper` called as object method  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
- Alias `agent` reporter to `minimal`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
- Respect diff config options in soft assertions  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
- Respect diff config options in soft assertions "  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
- **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
- **snapshot**: Increase default snapshot max output length  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
- **ui**: Fix jsx/tsx syntax highlight  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
- **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
- **api**: Make test-specification options writable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)
#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
</details>
<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>
### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
##### 🩹 Fixes
- Correct declaration entrypoint ([#&#8203;2826](https://github.com/vuejs/test-utils/pull/2826))
##### 🤖 CI
- Enable pkg.pr.new ([#&#8203;2827](https://github.com/vuejs/test-utils/pull/2827))
##### ❤️ Contributors
- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
##### 🚀 Enhancements
- Add Chinese docs translation ([#&#8203;2552](https://github.com/vuejs/test-utils/pull/2552))
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#&#8203;2655](https://github.com/vuejs/test-utils/pull/2655))
##### 🩹 Fixes
- Preserve code from keyboard events ([#&#8203;2434](https://github.com/vuejs/test-utils/pull/2434))
- Switch browser and require exports definitions ([#&#8203;2501](https://github.com/vuejs/test-utils/pull/2501))
- Re-add peer dependencies but with wider range ([#&#8203;2511](https://github.com/vuejs/test-utils/pull/2511))
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
- Resolve TypeScript type errors in .vitepress/config ([#&#8203;2549](https://github.com/vuejs/test-utils/pull/2549))
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
- Text() misses content for array functional component ([#&#8203;2579](https://github.com/vuejs/test-utils/pull/2579))
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
- **deps:** Update dependency vue-component-type-helpers to v3 ([#&#8203;2683](https://github.com/vuejs/test-utils/pull/2683))
- Remove wrapper div when unmount ([#&#8203;2700](https://github.com/vuejs/test-utils/pull/2700))
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#&#8203;2713](https://github.com/vuejs/test-utils/pull/2713))
- Add missing peerDependency [@&#8203;vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))
##### 💅 Refactors
- Enforce consistent usage of type imports ([#&#8203;2734](https://github.com/vuejs/test-utils/pull/2734))
##### 📖 Documentation
- Clarify findComponent vs getComponent ([#&#8203;2435](https://github.com/vuejs/test-utils/pull/2435))
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
- Add note about partial transition stub support ([#&#8203;2431](https://github.com/vuejs/test-utils/pull/2431))
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
- Fix plugin TS declaration example ([#&#8203;2466](https://github.com/vuejs/test-utils/pull/2466))
- Fixed incorrect checkbox value check ([#&#8203;2495](https://github.com/vuejs/test-utils/pull/2495))
- Capital letter in sentence fix ([#&#8203;2499](https://github.com/vuejs/test-utils/pull/2499))
- Import missing DOMWrapper on Implementation of the plugin section ([#&#8203;2519](https://github.com/vuejs/test-utils/pull/2519))
- Add migration step for deprecated ref syntax in findAllComponents ([#&#8203;2498](https://github.com/vuejs/test-utils/pull/2498))
- Correct anchor hash links and fix typo ([#&#8203;2551](https://github.com/vuejs/test-utils/pull/2551))
- Center logo on home ([#&#8203;2559](https://github.com/vuejs/test-utils/pull/2559))
- **zh-cn:** Review a-crash-course ([#&#8203;2563](https://github.com/vuejs/test-utils/pull/2563))
- Use code-group for install commands ([#&#8203;2571](https://github.com/vuejs/test-utils/pull/2571))
- **zh-cn:** Review event-handing.md ([#&#8203;2572](https://github.com/vuejs/test-utils/pull/2572))
- **zh-cn:** Enhance conditional-rendering.md ([#&#8203;2562](https://github.com/vuejs/test-utils/pull/2562))
- **zh-cn:** Review easy-to-test ([#&#8203;2567](https://github.com/vuejs/test-utils/pull/2567))
- **zh-cn:** Review passing-data.md ([#&#8203;2575](https://github.com/vuejs/test-utils/pull/2575))
- **zh-cn:** Review async-suspense.md ([#&#8203;2576](https://github.com/vuejs/test-utils/pull/2576))
- **zh:** 优化 API 文档格式和内容 ([#&#8203;2569](https://github.com/vuejs/test-utils/pull/2569))
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#&#8203;2578](https://github.com/vuejs/test-utils/pull/2578))
- **zh-cn:** Review http-requests.md ([#&#8203;2580](https://github.com/vuejs/test-utils/pull/2580))
- **zh-cn:** Review forms ([#&#8203;2582](https://github.com/vuejs/test-utils/pull/2582))
- **zh-cn:** Guide/advanced/slots.md ([#&#8203;2565](https://github.com/vuejs/test-utils/pull/2565))
- **zh:** Review extending-vtu ([#&#8203;2583](https://github.com/vuejs/test-utils/pull/2583))
- **zh:** Review index ([#&#8203;2584](https://github.com/vuejs/test-utils/pull/2584))
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
- **zh:** Review transitions, component-instance, and reusability-composition ([#&#8203;2616](https://github.com/vuejs/test-utils/pull/2616))
- **zh:** Review v-model and vuex ([#&#8203;2617](https://github.com/vuejs/test-utils/pull/2617))
- **zh:** Review all the rest advanced guide ([#&#8203;2619](https://github.com/vuejs/test-utils/pull/2619))
- **zh:** Review migration ([#&#8203;2623](https://github.com/vuejs/test-utils/pull/2623))
- Fix a typo in transitions.md ([#&#8203;2635](https://github.com/vuejs/test-utils/pull/2635))
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
- Update Essentials section to setup (composition api) ([#&#8203;2647](https://github.com/vuejs/test-utils/pull/2647))
- Typos in examples ([#&#8203;2678](https://github.com/vuejs/test-utils/pull/2678))
- Typo in easy-to-test.md ([#&#8203;2710](https://github.com/vuejs/test-utils/pull/2710))
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
- Updated example TodoApp to script setup ([#&#8203;2727](https://github.com/vuejs/test-utils/pull/2727))
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#&#8203;2743](https://github.com/vuejs/test-utils/pull/2743))
- Follow-up fixes for the conditional rendering guide ([#&#8203;2744](https://github.com/vuejs/test-utils/pull/2744))
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#&#8203;2799](https://github.com/vuejs/test-utils/pull/2799))
- Restore Options API component for data() mounting example ([#&#8203;2804](https://github.com/vuejs/test-utils/pull/2804))
- Promote Vitest as recommended test runner ([#&#8203;2805](https://github.com/vuejs/test-utils/pull/2805))
- **api:** Note that setValue does not accept objects on `<select>` ([#&#8203;2819](https://github.com/vuejs/test-utils/pull/2819))
##### 🏡 Chore
- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
- Expose Stubs type publicly ([#&#8203;2492](https://github.com/vuejs/test-utils/pull/2492))
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
- Use github issue forms ([#&#8203;2673](https://github.com/vuejs/test-utils/pull/2673))
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))
##### 🤖 CI
- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
- Add trusted publishing release workflow ([#&#8203;2825](https://github.com/vuejs/test-utils/pull/2825))
##### ❤️ Contributors
- Lachlan Miller ([@&#8203;lmiller1990](https://github.com/lmiller1990))
- cexbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Nicolas Bonamy ([@&#8203;nbonamy](https://github.com/nbonamy))
- KatWorkGit ([@&#8203;KatWorkGit](https://github.com/KatWorkGit))
- Wouter Kroes ([@&#8203;wouterkroes](https://github.com/wouterkroes))
- Rama Muhammad Murshal ([@&#8203;ramammurshal](https://github.com/ramammurshal))
- Evan You ([@&#8203;yyx990803](https://github.com/yyx990803))
- Vlad Starkovsky ([@&#8203;starkovsky](https://github.com/starkovsky))
- Joe ([@&#8203;joaoprp](https://github.com/joaoprp))
- Priyadarshi Kumar ([@&#8203;Psingh132](https://github.com/Psingh132))
- Sébastien Ronveaux ([@&#8203;sronveaux](https://github.com/sronveaux))
- Gilliam ([@&#8203;Gi11i4m](https://github.com/Gi11i4m))
- Baranov Dmytro ([@&#8203;dimas7001](https://github.com/dimas7001))
- BrendonHenrique ([@&#8203;BrendonHenrique](https://github.com/BrendonHenrique))
- Lorenz van Herwaarden ([@&#8203;lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
- wuzhiqing ([@&#8203;DDDDD12138](https://github.com/DDDDD12138))
- 阿菜 Cai ([@&#8203;RSS1102](https://github.com/RSS1102))
- Jinjiang ([@&#8203;Jinjiang](https://github.com/Jinjiang))
- Kylin ([@&#8203;lxKylin](https://github.com/lxKylin))
- Qianhe Chen ([@&#8203;chenqianhe](https://github.com/chenqianhe))
- 时瑶 ([@&#8203;KiritaniAyaka](https://github.com/KiritaniAyaka))
- h7ml ([@&#8203;h7ml](https://github.com/h7ml))
- Nicander ([@&#8203;Nicander93](https://github.com/Nicander93))
- Take-John ([@&#8203;takejohn](https://github.com/takejohn))
- ilyasherstoboev ([@&#8203;ilyasherstoboev](https://github.com/ilyasherstoboev))
- aimerie ([@&#8203;aimerie](https://github.com/aimerie))
- Miguel Rincon ([@&#8203;miguelrincon](https://github.com/miguelrincon))
- bcastlel ([@&#8203;bcastlel](https://github.com/bcastlel))
- Claudiu ([@&#8203;sofuxro](https://github.com/sofuxro))
- Artem Dragunov ([@&#8203;dragunovartem99](https://github.com/dragunovartem99))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Koen Mertens ([@&#8203;KCMertens](https://github.com/KCMertens))
- meomking ([@&#8203;CaptainWang98](https://github.com/CaptainWang98))
- Pepijn Olivier ([@&#8203;pepijnolivier](https://github.com/pepijnolivier))
- Tomina ([@&#8203;Thomaash](https://github.com/Thomaash))
- Gareth Jones ([@&#8203;G-Rath](https://github.com/G-Rath))
- Jerry Hogan ([@&#8203;hdJerry](https://github.com/hdJerry))
- Marco Pasqualetti ([@&#8203;marcalexiei](https://github.com/marcalexiei))
- guoxk ([@&#8203;guoxk-me](https://github.com/guoxk-me))
- kimulaco ([@&#8203;kimulaco](https://github.com/kimulaco))
- Erwan IQUEL ([@&#8203;Olympus5](https://github.com/Olympus5))
- Matt Van Horn ([@&#8203;mvanhorn](https://github.com/mvanhorn))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
</details>
<details>
<summary>eslint/eslint (eslint)</summary>
### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)
[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)
#### Bug Fixes
- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#&#8203;20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])
#### Documentation
- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#&#8203;20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh S M)
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs: Enable Eleventy incremental mode for watch ([#&#8203;20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh S M)
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#&#8203;20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#&#8203;20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)
#### Chores
- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#&#8203;20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#&#8203;20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#&#8203;20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test ([#&#8203;20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#&#8203;20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#&#8203;20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)
</details>
<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #&#8203;705, #&#8203;817
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)
- allow numerical external entity for backward compatibility
- fix [#&#8203;705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
- fix [#&#8203;817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long
### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @&#8203;nodable/entities and FXB
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)
- Use `@nodable/entities` v2.1.0
- breaking changes
- single entity scan. You're not allowed to use entity value to form another entity name.
- you cant add numeric external entity
- entity error message when expantion limit is crossed might change
- typings are updated for new options related to process entity
- please follow documentation of `@nodable/entities` for more detail.
- performance
- if processEntities is false, then there should not be impact on performance.
- if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
- if processEntities is true, and you pass entity decoder separately
- if no entity then performance should be same as before
- if there are entities then performance should be increased from past versions
- ignoreAttributes is not required to be set to set xml version for NCR entity value
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content
### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
</details>
<details>
<summary>versatica/mediasoup (mediasoup)</summary>
### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)
- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #&#8203;1780](https://github.com/versatica/mediasoup/pull/1780)).
### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)
- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #&#8203;1777](https://github.com/versatica/mediasoup/pull/1777)).
</details>
<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
</details>
<details>
<summary>vuejs/core (vue)</summary>
### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)
[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)
##### Bug Fixes
- **compiler-sfc:** handle nested :deep in selector pseudos ([#&#8203;14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#&#8203;14724](https://github.com/vuejs/core/issues/14724)
- **reactivity:** unlink effect scopes on out-of-order off ([#&#8203;14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#&#8203;14733](https://github.com/vuejs/core/issues/14733)
- **runtime-dom:** preserve textarea resize dimensions ([#&#8203;14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#&#8203;14741](https://github.com/vuejs/core/issues/14741)
- **teleport:** don't move teleport children if not mounted ([#&#8203;14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#&#8203;14701](https://github.com/vuejs/core/issues/14701)
- **transition:** preserve placeholder for conditional explicit default slots ([#&#8203;14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#&#8203;14727](https://github.com/vuejs/core/issues/14727)
</details>
<details>
<summary>vuejs/router (vue-router)</summary>
### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)
[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
#####    🐞 Bug Fixes
- Missing closing quote in generated import  -  by [@&#8203;zjy040525](https://github.com/zjy040525) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)
#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)
[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
#####    🚀 Features
- Enable standard schema param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
- Normalize param parsers once  -  by [@&#8203;posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)
#####    🐞 Bug Fixes
- Track definePage imports per-file to fix named view race condition  -  by [@&#8203;posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
- Avoid double decoding hash on string location  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.3] - 2026-04-19
### Changed
- update dependency mediasoup-client to v3.18.8 (#30)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.18.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.7/3.18.8?slim=true) |
---
### Release Notes
<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.2] - 2026-04-15
### Changed
- Update dependency fast-xml-parser to v5.6.0 (#28)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.12/5.6.0?slim=true) |
---
### Release Notes
<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @&#8203;nodable/entities to replace entities
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
- No API change
- No change in performance for basic usage
- No typing change
- No config change
- new dependency
- breaking: error messages for entities might have been changed.
-
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
</details>
---
### Configuration
📅 **Schedule**: (UTC)
- 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:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.1] - 2026-04-15
### Changed
- split push release/publish and harden workflows (#27)
### Added
* Separate release from Docker/Helm publish
* enrich releases with PRbodies when available
* tighten release.sh validation and idempotency
* trim PR docker-build metadata for act-runner stability
## [1.1.0] - 2026-04-15
### Changed
- Update all non-major dependencies (#25)
## [1.0.10] - 2026-04-15
### Changed
- Remove npm overrides for tar (#26)
## [1.0.9] - 2026-03-24
### Changed
- update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
## [1.0.8] - 2026-03-12
### Changed
- fix release file (#22)
## [1.0.7] - 2026-03-06
### Changed
- chore(deps): update docker/build-push-action action to v7 (#19)
## [1.0.6] - 2026-03-05
### Changed
- fix docker login during push stage (#18)
## [1.0.5] - 2026-03-05
### Changed
- fix deploy pipeline stages for token registry uploads (#17)
## [1.0.4] - 2026-03-04
### Changed
- fix deploy pipeline (#15)
## [1.0.3] - 2026-02-23
### Changed
- fix(deps): update dependency vue-router to v5 (#12)
## [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)
+1 -2
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"]
+27 -16
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. 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). 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`
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage :key="$route.path" />
<NuxtPage />
</NuxtLayout>
</template>
+38 -2
View File
@@ -16,6 +16,9 @@
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
.kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
.kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
.cot-icon-rotatable { @apply inline-flex origin-center; }
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
@@ -84,12 +87,14 @@
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar {
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
border-color: rgba(34, 201, 201, 0.35) !important;
}
.kestrel-map-container .leaflet-control-zoom a,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar a {
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
width: 32px !important;
@@ -105,9 +110,14 @@
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate:hover,
.kestrel-map-container .leaflet-control-alpr:hover,
.kestrel-map-container .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
color: #ef4444 !important;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
}
.kestrel-map-container .savetiles.leaflet-bar {
@apply flex flex-col;
}
@@ -119,12 +129,38 @@
padding: 6px 10px !important;
font-size: 11px !important;
}
.kestrel-map-container .leaflet-control-locate {
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
.kestrel-map-container .leaflet-control-locate svg,
.kestrel-map-container .leaflet-control-alpr svg {
color: currentColor;
}
.kestrel-map-container .alpr-cone {
display: inline-flex;
transform-origin: center center;
}
.kestrel-map-container .alpr-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .alpr-cluster {
@apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.kestrel-map-container .cot-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .cot-cluster {
@apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
+6 -1
View File
@@ -26,6 +26,11 @@
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<NuxtLink
v-else
to="/login"
@@ -75,7 +80,7 @@ watch(sidebarCollapsed, (v) => {
}
})
const { user, refresh } = useUser()
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
+154 -12
View File
@@ -47,11 +47,41 @@
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
<div
v-if="mapContext"
class="pointer-events-auto absolute right-3 top-3 z-[1000] flex gap-0.5 rounded border border-kestrel-border bg-kestrel-surface/95 p-0.5 text-xs shadow-glow"
data-testid="cot-layer-toggles"
>
<button
v-for="layer in COT_LAYERS"
:key="layer.key"
type="button"
class="kestrel-cot-layer-btn"
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
@click="emit('toggleCotLayer', layer.key)"
>
{{ layer.label }}
</button>
</div>
</div>
</template>
<script setup>
import 'leaflet/dist/leaflet.css'
import {
createAlprControl,
createAlprLayer,
setAlprControlPressed,
syncAlprLayer,
} from '~/utils/alprMapLayer.js'
import {
createCotLayer,
getCotClusters,
loadCotCluster,
syncCotLayer,
} from '~/utils/cotMapLayer.js'
import { clearFeatureMarkers } from '~/utils/mapMarkerSync.js'
const props = defineProps({
devices: {
@@ -66,13 +96,29 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotEntities: {
type: Array,
default: () => [],
},
cotLayers: {
type: Object,
default: () => ({ air: true, surface: true, ground: true }),
},
alprMarkers: {
type: Array,
default: () => [],
},
showAlpr: {
type: Boolean,
default: false,
},
canEditPois: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null)
const contextMenuRef = ref(null)
@@ -81,6 +127,9 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotLayerRef = ref(null)
const cotMapView = ref(null)
const alprLayerRef = ref(null)
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -89,6 +138,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'
@@ -98,6 +148,11 @@ const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/'
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const COT_LAYERS = Object.freeze([
{ key: 'air', label: 'Air' },
{ key: 'surface', label: 'Surface' },
{ key: 'ground', label: 'Team' },
])
const ICON_SIZE = 28
@@ -124,7 +179,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 +190,44 @@ function getLiveSessionIcon(L) {
})
}
function refreshCotMapView(map) {
const bounds = map.getBounds()
cotMapView.value = {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
}
}
function renderCotLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
const layer = cotLayerRef.value
if (!ctx?.map || !L || !layer) return
const view = cotMapView.value
const features = view ? getCotClusters(view) : []
syncCotLayer(L, ctx.map, layer, features)
}
function reloadCotCluster() {
loadCotCluster(props.cotEntities || [])
renderCotLayer()
}
function emitBounds(map) {
refreshCotMapView(map)
const bounds = map.getBounds()
emit('boundsChange', {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
})
}
function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
@@ -143,7 +236,7 @@ function createMap(initialCenter) {
? initialCenter
: DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' })
@@ -169,6 +262,14 @@ function createMap(initialCenter) {
}
locateControl.addTo(map)
const alprControl = createAlprControl(L, {
showAlpr: props.showAlpr,
onToggle: () => emit('toggleAlpr'),
})
alprControl.addTo(map)
const alprLayer = createAlprLayer(L, map)
const cotLayer = createCotLayer(L, map)
const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS,
@@ -197,11 +298,28 @@ function createMap(initialCenter) {
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
})
mapContext.value = { map, layer: baseLayer, control, locateControl }
map.on('moveend', () => {
emitBounds(map)
renderCotLayer()
})
map.on('zoomend', () => {
emitBounds(map)
renderCotLayer()
})
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
alprLayerRef.value = alprLayer
cotLayerRef.value = cotLayer
refreshCotMapView(map)
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
nextTick(() => map.invalidateSize())
reloadCotCluster()
updateAlprLayer()
nextTick(() => {
map.invalidateSize()
emitBounds(map)
})
}
function updateMarkers() {
@@ -291,6 +409,18 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateAlprLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
const layer = alprLayerRef.value
if (!ctx?.map || !L || !layer) return
if (!props.showAlpr) {
clearFeatureMarkers(layer)
return
}
syncAlprLayer(L, ctx.map, layer, props.alprMarkers || [])
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
@@ -376,15 +506,22 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
clearFeatureMarkers(cotLayerRef.value)
clearFeatureMarkers(alprLayerRef.value)
const ctx = mapContext.value
if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
if (ctx.alprControl && ctx.map) ctx.map.removeControl(ctx.alprControl)
if (cotLayerRef.value && ctx.map) ctx.map.removeLayer(cotLayerRef.value)
if (alprLayerRef.value && ctx.map) ctx.map.removeLayer(alprLayerRef.value)
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove()
mapContext.value = null
}
cotLayerRef.value = null
alprLayerRef.value = null
}
function initMapWithLocation() {
@@ -404,8 +541,6 @@ function initMapWithLocation() {
)
}
let resizeObserver = null
onMounted(async () => {
if (!import.meta.client || typeof document === 'undefined') return
const [leaflet, offline] = await Promise.all([
@@ -428,10 +563,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 +577,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 +587,11 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => reloadCotCluster())
watch(() => props.alprMarkers, () => updateAlprLayer())
watch(() => props.showAlpr, (enabled) => {
setAlprControlPressed(mapContext.value?.alprControl, enabled)
if (enabled && mapContext.value?.map) emitBounds(mapContext.value.map)
else updateAlprLayer()
})
</script>
+36 -36
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) {
+146
View File
@@ -0,0 +1,146 @@
import { createClusterIndex } from '~/utils/mapCluster.js'
import {
MAX_BBOX_DEGREES,
bboxFetchKey,
bboxToTileKey,
tileKey,
tilesNearCenter,
} from '~/utils/alprViewport.js'
const STORAGE_KEY = 'kestrelos:showAlprLayer'
const MIN_FETCH_ZOOM = 10
const MAX_TILE_FETCHES = 16
const MAX_CACHED_TILES = 64
const TILE_RETENTION = 3
const EMPTY_TILES = Object.freeze({})
const mergeFeatures = lists => Object.values(
lists.flat().reduce((acc, feature) => {
const id = feature.id ?? feature.properties?.osmId
return id == null ? acc : { ...acc, [id]: feature }
}, {}),
)
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
const retentionKeys = bounds => new Set(
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
return offsets(TILE_RETENTION).flatMap(dr =>
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
)
}),
)
const pruneTileCache = (cache, bounds) => {
const keep = retentionKeys(bounds)
const retained = Object.fromEntries(
Object.entries(cache).filter(([key]) => keep.has(key)),
)
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
if (overflow <= 0) return Object.freeze(retained)
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
const drop = new Set(
Object.keys(retained)
.map((key) => {
const [r, c] = key.split(',').map(Number)
return { key, dist: Math.hypot(r - cr, c - cc) }
})
.sort((a, b) => b.dist - a.dist)
.slice(0, overflow)
.map(({ key }) => key),
)
return Object.freeze(
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
)
}
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
export function useAlprCameras() {
const showAlpr = useState('showAlprLayer', () => {
if (!import.meta.client) return true
return localStorage.getItem(STORAGE_KEY) !== '0'
})
const view = ref(null)
const tiles = ref(EMPTY_TILES)
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
const debounceTimer = ref(null)
const requestId = ref(0)
const lastFetchKey = ref('')
const applyTiles = (next) => {
tiles.value = next
cluster.load(mergeFeatures(Object.values(next)))
}
watch(showAlpr, (enabled) => {
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
if (!enabled) {
applyTiles(EMPTY_TILES)
view.value = null
lastFetchKey.value = ''
}
})
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
const toggleAlpr = () => {
showAlpr.value = !showAlpr.value
}
const onBoundsChange = (bounds) => {
if (!showAlpr.value || !bounds) return
view.value = bounds
const pruned = pruneTileCache(tiles.value, bounds)
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
const key = bboxFetchKey(bounds)
if (key === lastFetchKey.value) return
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
lastFetchKey.value = key
fetchViewport(bounds)
}, 400)
}
const fetchViewport = async (bounds) => {
const id = requestId.value + 1
requestId.value = id
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
try {
const fetched = missing.length
? await Promise.all(missing.map(async (tile) => {
const params = new URLSearchParams({
south: String(tile.south),
west: String(tile.west),
north: String(tile.north),
east: String(tile.east),
})
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
return [bboxToTileKey(tile), data?.features ?? []]
}))
: []
if (id !== requestId.value) return
const merged = Object.freeze({
...tiles.value,
...Object.fromEntries(fetched),
})
const pruned = pruneTileCache(merged, bounds)
applyTiles(pruned)
}
catch { /* keep last good index */ }
}
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'kestrel-cot-layers'
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
function loadLayers() {
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_LAYERS }
const parsed = JSON.parse(raw)
return {
air: parsed.air !== false,
surface: parsed.surface !== false,
ground: parsed.ground !== false,
}
}
catch {
return { ...DEFAULT_LAYERS }
}
}
function saveLayers(layers) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
}
catch { /* ignore quota */ }
}
export function useCotLayers() {
const layers = ref(loadLayers())
const layerQuery = computed(() => {
const parts = []
if (layers.value.air) parts.push('air')
if (layers.value.surface) parts.push('surface')
if (layers.value.ground) parts.push('ground')
return parts.length ? parts.join(',') : 'none'
})
function toggleLayer(name) {
if (!(name in DEFAULT_LAYERS)) return
layers.value = { ...layers.value, [name]: !layers.value[name] }
saveLayers(layers.value)
}
return Object.freeze({ layers, layerQuery, toggleLayer })
}
+117
View File
@@ -0,0 +1,117 @@
import { bboxFetchKey } from '~/utils/alprViewport.js'
const DEBOUNCE_MS = 300
const EMPTY_ENTITIES = Object.freeze({})
const expandBounds = (bounds, factor = 0.25) => {
const latPad = (bounds.north - bounds.south) * factor
const lngPad = (bounds.east - bounds.west) * factor
return {
south: Math.max(-90, bounds.south - latPad),
north: Math.min(90, bounds.north + latPad),
west: bounds.west - lngPad,
east: bounds.east + lngPad,
}
}
const entitiesFromList = list => Object.freeze(
Object.fromEntries(
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
),
)
export function useCotStream(boundsRef, layerQueryRef) {
const entities = ref(EMPTY_ENTITIES)
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
const eventSource = ref(null)
const debounceTimer = ref(null)
const subscribedKey = ref('')
const streamBounds = ref(null)
const setEntities = (record) => {
entities.value = Object.freeze(record)
}
const parseEvent = (e, fn) => {
try {
fn(JSON.parse(e.data))
}
catch { /* ignore */ }
}
const closeStream = () => {
eventSource.value?.close()
eventSource.value = null
}
const connect = () => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
const bounds = streamBounds.value ?? boundsRef.value
if (!bounds) return
closeStream()
const q = new URLSearchParams({
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
layers: unref(layerQueryRef) || 'air,surface,ground',
})
const es = new EventSource(`/api/cot/stream?${q}`)
eventSource.value = es
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
setEntities(entitiesFromList(list))
}))
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
if (!entity?.id) return
setEntities({ ...entities.value, [entity.id]: entity })
}))
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
if (!id) return
setEntities(Object.fromEntries(
Object.entries(entities.value).filter(([key]) => key !== String(id)),
))
}))
es.onerror = () => {
closeStream()
scheduleConnect()
}
}
const scheduleConnect = () => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
debounceTimer.value = null
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
connect()
}, DEBOUNCE_MS)
}
const maybeReconnect = (bounds) => {
if (!bounds) return
const key = bboxFetchKey(bounds)
if (key === subscribedKey.value) return
subscribedKey.value = key
streamBounds.value = expandBounds(bounds)
scheduleConnect()
}
watch(boundsRef, maybeReconnect, { deep: true })
watch(layerQueryRef, () => {
subscribedKey.value = ''
scheduleConnect()
})
onMounted(() => {
if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
})
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
})
onBeforeUnmount(() => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
closeStream()
})
return Object.freeze({ cotEntities })
}
+5 -5
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
}
+3 -2
View File
@@ -2,12 +2,13 @@ const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() {
const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData(
const { data: user, refresh, status } = useAsyncData(
'user',
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null },
)
const authPending = computed(() => status.value === 'pending')
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin')
return Object.freeze({ user, canEditPois, isAdmin, refresh })
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
}
+3 -3
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)
+1 -1
View File
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === LOGIN_PATH) return
const { user, refresh } = useUser()
await refresh()
if (!user.value) await refresh()
if (user.value) return
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
+100
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>
+22
View File
@@ -6,11 +6,24 @@
:devices="devices ?? []"
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:cot-layers="cotLayers"
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
:show-alpr="showAlpr"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@refresh-pois="refreshPois"
@bounds-change="onMapBoundsChange"
@toggle-alpr="toggleAlpr"
@toggle-cot-layer="toggleLayer"
/>
<template #fallback>
<div
class="h-full min-h-[300px] bg-kestrel-bg"
aria-hidden="true"
/>
</template>
</ClientOnly>
</div>
<CameraViewer
@@ -25,8 +38,17 @@
const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const { showAlpr, toggleAlpr, alprMarkers, onBoundsChange: onAlprBoundsChange } = useAlprCameras()
const { layers: cotLayers, layerQuery, toggleLayer } = useCotLayers()
const mapBounds = ref(null)
const { cotEntities } = useCotStream(mapBounds, layerQuery)
const selectedCamera = ref(null)
function onMapBoundsChange(bounds) {
mapBounds.value = bounds
onAlprBoundsChange(bounds)
}
function onSelectLive(session) {
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
}
+1 -1
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 }}
+85
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>
+54 -54
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
+220
View File
@@ -0,0 +1,220 @@
import { syncFeatureMarkers } from './mapMarkerSync.js'
const ICON_SIZE = 28
const CONE_SIZE = 52
const ALPR_COLOR = '#ef4444'
const DEFAULT_FOV = 60
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function conePath(fov) {
const half = Math.min(Math.max(fov / 2, 10), 85)
const cx = 26
const cy = 26
const r = 24
const toRad = deg => ((deg - 90) * Math.PI) / 180
const x1 = cx + r * Math.cos(toRad(-half))
const y1 = cy + r * Math.sin(toRad(-half))
const x2 = cx + r * Math.cos(toRad(half))
const y2 = cy + r * Math.sin(toRad(half))
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
}
function compassLabel(deg) {
if (!Number.isFinite(deg)) return null
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
return dirs[idx]
}
function wikidataLink(label, qid) {
if (!qid) return escapeHtml(label)
const id = escapeHtml(qid)
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
}
function popupLine(parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
function titleHtml(props) {
if (props.name) return escapeHtml(props.name)
if (props.model) {
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) {
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
return escapeHtml(makeModel)
}
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
if (props.ref) return escapeHtml(props.ref)
return 'ALPR camera'
}
function titleText(props) {
if (props.name) return props.name
if (props.model) {
return [props.manufacturer, props.model].filter(Boolean).join(' ')
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) return makeModel
if (props.operator) return props.operator
if (props.ref) return props.ref
return 'ALPR camera'
}
function modelLineHtml(props) {
if (props.model) {
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
}
if (props.modelUnknown) {
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
}
return ''
}
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
export function formatAlprPopup(props) {
const title = titleHtml(props)
const headline = titleText(props)
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
const identity = []
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
if (props.operator && headline !== props.operator) {
identity.push(wikidataLink(props.operator, props.operatorWikidata))
}
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
if (props.brand) identity.push(escapeHtml(props.brand))
const view = []
if (props.direction != null) {
const deg = Math.round(props.direction)
const compass = compassLabel(props.direction)
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
}
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
const note = props.description || props.note
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
const identityHtml = identity.length
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
: ''
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
}
function popupHtml(props) {
return formatAlprPopup(props)
}
function pointIcon(L, direction, fov) {
const hasDirection = Number.isFinite(direction)
if (!hasDirection) {
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [CONE_SIZE, CONE_SIZE],
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
})
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'alpr-cluster-icon',
html: `<span class="alpr-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
export function createAlprControl(L, { showAlpr, onToggle }) {
const control = L.control({ position: 'topleft' })
control.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-alpr'
el.title = 'Toggle ALPR cameras (OSM)'
el.setAttribute('aria-label', 'Toggle ALPR cameras')
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
el.addEventListener('click', onToggle)
control._button = el
return el
}
return control
}
export function setAlprControlPressed(control, pressed) {
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.osmId
return id != null ? `a:${id}` : null
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createAlprLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncAlprLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
marker.setIcon(icon)
if (!isCluster) marker.setPopupContent(popupHtml(props))
},
})
}
+77
View File
@@ -0,0 +1,77 @@
export const MAX_BBOX_DEGREES = 0.5
export function tileKey(row, col) {
return `${row},${col}`
}
export function bboxToTileKey(bbox) {
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
return tileKey(row, col)
}
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
const south = row * step
const west = col * step
return { south, west, north: south + step, east: west + step }
}
export function bboxFetchKey(bounds) {
const zoom = bounds.zoom ?? 14
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
const q = v => Math.round(v / step) * step
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
}
function ringOffsets(radius) {
if (radius === 0) return [[0, 0]]
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
.map(dc => [dr, dc]))
}
function collectTiles(state) {
const {
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
} = state
if (tiles.length >= limit) return tiles
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
const { nextSeen, added } = ringOffsets(radius)
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
.filter(([row, col]) => inBounds(row, col))
.reduce((acc, [row, col]) => {
const key = `${row},${col}`
if (acc.nextSeen.has(key)) return acc
return {
nextSeen: new Set([...acc.nextSeen, key]),
added: [...acc.added, tileBox(row, col, step)],
}
}, { nextSeen: seen, added: [] })
if (added.length === 0 && radius > 0) return tiles
const nextTiles = [...tiles, ...added].slice(0, limit)
if (nextTiles.length >= limit) return nextTiles
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
}
export function tilesNearCenter(bounds, limit) {
const step = MAX_BBOX_DEGREES
const lat = (bounds.south + bounds.north) / 2
const lng = (bounds.west + bounds.east) / 2
return collectTiles({
centerRow: Math.floor(lat / step),
centerCol: Math.floor(lng / step),
minRow: Math.floor(bounds.south / step),
maxRow: Math.ceil(bounds.north / step) - 1,
minCol: Math.floor(bounds.west / step),
maxCol: Math.ceil(bounds.east / step) - 1,
step,
limit,
radius: 0,
seen: new Set(),
tiles: [],
})
}
+131
View File
@@ -0,0 +1,131 @@
/** Map CoT / ADS-B entity display: icons and popups. */
export const COT_COLORS = {
air: '#60a5fa',
helicopter: '#fbbf24',
surface: '#38bdf8',
ground: '#f59e0b',
}
export function cotCategory(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
export function cotAirIconKind(entity) {
const type = entity?.type ?? ''
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
return 'fixedWing'
}
function iconWrap(heading, inner) {
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
}
const PLANE_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
const HELI_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
const SHIP_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
const GROUND_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
export function getCotIconHtml(entity) {
const category = cotCategory(entity?.type)
const heading = Number(entity?.heading)
if (category === 'air') {
const kind = cotAirIconKind(entity)
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
}
if (category === 'surface') {
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
}
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
}
function msToKnots(ms) {
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
}
function metersToFeet(m) {
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
}
function fmtHeading(deg) {
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
}
function fmtVerticalFpm(ms) {
if (!Number.isFinite(ms) || ms === 0) return null
const fpm = Math.round(ms * 196.85)
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
}
function icaoFromEntity(entity) {
if (entity?.icao) return String(entity.icao).toUpperCase()
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
return entity.id.slice(5).toUpperCase()
}
return null
}
function mmsiFromEntity(entity) {
if (entity?.mmsi) return String(entity.mmsi)
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
return null
}
function popupLine(escape, parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
/**
* @param {Record<string, unknown>} entity
* @param {(s: string) => string} escape
*/
export function formatCotPopup(entity, escape) {
const category = cotCategory(entity?.type)
const label = escape(entity?.label || entity?.id || 'Unknown')
if (entity?.source === 'adsb' || category === 'air') {
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
const icao = icaoFromEntity(entity)
const meta = [
icao ? `ICAO ${icao}` : null,
entity?.originCountry ? escape(String(entity.originCountry)) : null,
].filter(Boolean).join(' · ')
const alt = metersToFeet(entity?.altitude)
const stats = [
alt != null ? `${alt.toLocaleString()} ft` : null,
entity?.onGround ? 'On ground' : null,
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
fmtHeading(entity?.heading),
fmtVerticalFpm(entity?.verticalRate),
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
if (entity?.source === 'ais' || category === 'surface') {
const mmsi = mmsiFromEntity(entity)
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
const stats = [
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
fmtHeading(entity?.heading),
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
}
+107
View File
@@ -0,0 +1,107 @@
import { createClusterIndex } from './mapCluster.js'
import { syncFeatureMarkers } from './mapMarkerSync.js'
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
const ICON_SIZE = 28
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
export function entitiesToFeatures(entities) {
return (entities || [])
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
.map(e => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
properties: { entity: e, cotCategory: cotCategory(e.type) },
}))
}
export function loadCotCluster(entities) {
CLUSTER.load(entitiesToFeatures(entities))
}
export function getCotClusters(view) {
return CLUSTER.query(view)
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.entity?.id
return id != null ? `e:${id}` : null
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'cot-cluster-icon',
html: `<span class="cot-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
function entityIcon(L, entity) {
const { html, className } = getCotIconHtml(entity)
return L.divIcon({
className: `poi-div-icon cot-entity-icon ${className}`,
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
})
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createCotLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncCotLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else if (props.entity) {
marker.bindPopup(
formatCotPopup(props.entity, escapeHtml),
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
)
}
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
marker.setIcon(icon)
if (!isCluster && props.entity) {
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
}
},
})
}
+5 -5
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)
+31
View File
@@ -0,0 +1,31 @@
import Supercluster from 'supercluster'
export function createClusterIndex(options = {}) {
const index = new Supercluster(options)
const state = { features: Object.freeze([]) }
return {
load(features) {
const list = Object.freeze([...(features ?? [])])
index.load(list)
state.features = list
},
query(view) {
if (!view || state.features.length === 0) return []
const { west, south, east, north, zoom } = view
return index.getClusters(
[west, south, east, north],
Math.floor(zoom ?? 14),
).map((feature) => {
if (!feature.properties?.cluster) return feature
return {
...feature,
properties: {
...feature.properties,
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
},
}
})
},
}
}
+37
View File
@@ -0,0 +1,37 @@
const SYNC_KEY = '_kestrelMarkerSync'
const pointFeatures = (features, keyFor) => (features ?? [])
.filter(f => f?.geometry?.type === 'Point')
.map(f => ({ feature: f, key: keyFor(f) }))
.filter(({ key }) => key != null)
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
const prev = layer[SYNC_KEY] ?? new Map()
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
const existing = prev.get(key)
if (existing) {
update(existing, feature)
return new Map([...map, [key, existing]])
}
const marker = create(feature)
layer.addLayer(marker)
return new Map([...map, [key, marker]])
}, new Map())
Array.from(prev.entries())
.filter(([key]) => !next.has(key))
.forEach(([, marker]) => layer.removeLayer(marker))
layer[SYNC_KEY] = next
}
export function clearFeatureMarkers(layer) {
if (!layer) return
const prev = layer[SYNC_KEY]
if (prev) {
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
layer[SYNC_KEY] = new Map()
return
}
layer.clearLayers()
}
+11
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)
+80
View File
@@ -0,0 +1,80 @@
# 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.
ADS-B and AIS via [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot): see [tracking.md](tracking.md).
## 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_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
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
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+44
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 |
+62
View File
@@ -0,0 +1,62 @@
# 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
- **ALPR (OSM / DeFlock)** - Crowdsourced license-plate cameras from OpenStreetMap; toggle on the map (camera icon control). Reference only, no stream.
- **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.
## ALPR layer (DeFlock / OpenStreetMap)
deflock.me has no bulk download API. KestrelOS queries OpenStreetMap via Overpass (`surveillance:type=ALPR`) and returns **GeoJSON FeatureCollection** from `GET /api/alpr`.
- **Map:** ALPR layer is on by default (toggle top-left to hide). Marker popups show OSM identifying tags (manufacturer, model, operator, ref, Wikidata, etc.) when contributors tagged them.
- **Offline:** Run `npm run import:alpr` to preload SQLite; cache serves automatically when Overpass is unreachable.
Attribution: © OpenStreetMap contributors.
## POIs
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

+48
View File
@@ -0,0 +1,48 @@
# ADS-B and AIS
Aircraft and vessels use the same **CoT** store as ATAK/iTAK. The map consumes `GET /api/cot/stream` (SSE, viewport bbox). Toggle **Air**, **Surface**, and **Team** on the map.
## Accuracy tiers
1. **Tactical (best):** local SDR/AIS receiver → [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot) → KestrelOS CoT `:8089` (sub-second updates).
2. **Vessels (live OSINT):** AISStream WebSocket push as vessels transmit.
3. **Aircraft (awareness OSINT):** OpenSky bbox poll — not a live stream; typical lag ~5s.
For tactical use, run local receivers. Do not rely on OpenSky alone.
## Freshness
Tracks update via SSE `update` events (CoT `:8089`, AISStream) or coalesced `snapshot` after each OpenSky poll. Stale tracks are removed automatically (team ~90s, OSINT ~30s without a new fix).
OSINT feeds run only while a map client is connected (SSE subscriber). Keep the map tab visible for live updates.
## Self-hosted
**ADS-B:** [adsbcot](https://github.com/snstac/adsbcot) → `tls://host:8089`
```ini
[adsbcot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp+beast://127.0.0.1:30005
```
**AIS:** [aiscot](https://github.com/snstac/aiscot) → `tls://host:8089`
```ini
[aiscot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp://127.0.0.1:10110
```
Use KestrelOS credentials (see [atak-itak.md](atak-itak.md)).
## OSINT APIs (optional)
Set these only if you want viewport OSINT without local receivers:
| Variable | Purpose |
|----------|---------|
| `AISSTREAM_API_KEY` | AISStream WebSocket |
| `OPENSKY_CLIENT_ID` / `OPENSKY_CLIENT_SECRET` | OpenSky OAuth (recommended for production) |
UIDs: `ICAO.*` (ADS-B), `MMSI.*` (AIS). Icons follow CoT type (`a-f-A-*`, `a-f-S-*`, `a-f-G-*`).
+2 -2
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.1.6
appVersion: "1.1.6"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image:
repository: git.keligrubb.com/keligrubb/kestrelos
tag: 0.4.0
tag: 1.1.6
pullPolicy: IfNotPresent
service:
+5 -1
View File
@@ -32,10 +32,14 @@ export default defineNuxtConfig({
public: {
version: pkg.version ?? '',
},
cotRequireAuth: true,
cotDebug: false,
aisstreamApiKey: process.env.AISSTREAM_API_KEY || '',
openskyClientId: process.env.OPENSKY_CLIENT_ID || '',
openskyClientSecret: process.env.OPENSKY_CLIENT_SECRET || '',
},
devServer: {
host: '0.0.0.0',
port: 3000,
...(useDevHttps
? { https: { key: devKey, cert: devCert } }
: {}),
+5036 -4907
View File
File diff suppressed because it is too large Load Diff
+27 -25
View File
@@ -1,6 +1,6 @@
{
"name": "kestrelos",
"version": "0.4.0",
"version": "1.1.6",
"private": true,
"type": "module",
"scripts": {
@@ -10,40 +10,42 @@
"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",
"test:e2e:debug": "playwright test --debug test/e2e",
"test:e2e:install": "playwright install --with-deps webkit chromium firefox",
"lint": "eslint . --max-warnings 0"
"lint": "eslint . --max-warnings 0",
"import:alpr": "node scripts/import-alpr.js"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxt/icon": "^2.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"hls.js": "^1.5.0",
"fast-xml-parser": "^5.9.3",
"hls.js": "^1.6.16",
"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",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",
"ws": "^8.18.0"
"leaflet.offline": "^3.2.1",
"mediasoup": "^3.20.9",
"mediasoup-client": "^3.21.0",
"nuxt": "^4.4.8",
"openid-client": "^6.8.4",
"qrcode": "^1.5.4",
"supercluster": "^8.0.1",
"vue": "^3.5.38",
"vue-router": "^5.1.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.26",
"@nuxt/eslint": "^1.15.0",
"@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.58.2",
"@vitest/coverage-v8": "^4.0.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^9.0.0",
"happy-dom": "^20.6.1",
"vitest": "^4.0.0"
},
"overrides": {
"tar": "^7.5.7"
"@iconify-json/tabler": "^1.2.35",
"@nuxt/eslint": "^1.16.0",
"@nuxt/test-utils": "^4.0.3",
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"eslint": "^10.5.0",
"happy-dom": "^20.10.6",
"vitest": "^4.1.9"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { getDb, closeDb } from '../server/utils/db.js'
import { importAllAlprNodes } from '../server/utils/alpr.js'
try {
const db = await getDb()
console.log('[import-alpr] Fetching ALPR nodes from Overpass…')
const count = await importAllAlprNodes(db)
console.log(`[import-alpr] Cached ${count} nodes in SQLite.`)
}
catch (error) {
console.error('[import-alpr] Failed:', error?.message || error)
process.exitCode = 1
}
finally {
closeDb()
}
+42 -9
View File
@@ -3,18 +3,51 @@ set -e
# version
msg="${CI_COMMIT_MESSAGE:-}"
# optional PR body (written by workflow from Gitea API when this commit is a merged PR)
if [ -f .ci_pr_body ]; then
CI_PR_DESCRIPTION=$(cat .ci_pr_body); rm -f .ci_pr_body
else
CI_PR_DESCRIPTION=""
fi
export CI_PR_DESCRIPTION
bump=patch
echo "$msg" | grep -qi minor: && bump=minor
# Conventional commits: chore/fix => patch, feat => minor
echo "$msg" | grep -Eqi '(^|[[:space:]])(fix|chore)(\([^)]*\))?:' && bump=patch
echo "$msg" | grep -Eqi '(^|[[:space:]])feat(\([^)]*\))?:' && bump=minor
# Conventional commits breaking change: type!:
echo "$msg" | grep -Eqi '(^|[[:space:]])[a-zA-Z]+(\([^)]*\))?!:' && bump=major
# Explicit bump prefixes still supported (but never downgrade a major bump)
echo "$msg" | grep -qi minor: && [ "$bump" != "major" ] && bump=minor
echo "$msg" | grep -qi major: && bump=major
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$cur" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $cur)"; exit 1 ;;
esac
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
newVersion="$major.$minor.$patch"
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
# changelog entry (strip prefix from first line)
changelogEntry=$(echo "$msg" | head -1 | awk '{sub(/^[mM]ajor:[ \t]*/,""); sub(/^[mM]inor:[ \t]*/,""); sub(/^[pP]atch:[ \t]*/,""); print}')
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
if [ -n "$(git ls-remote "$url" "refs/tags/v$newVersion" 2>/dev/null)" ]; then
exit 0
fi
# changelog entry (strip explicit bump prefixes & any conventional-commit type(scope):); optional PR description enriches it
changelogEntry=$(
echo "$msg" \
| head -1 \
| sed -E 's/^[[:space:]]*[mM]ajor:[[:space:]]*//; s/^[[:space:]]*[mM]inor:[[:space:]]*//; s/^[[:space:]]*[pP]atch:[[:space:]]*//' \
| sed -E 's/^[[:space:]]*[a-zA-Z]+(\([^)]*\))?:[[:space:]]*//'
)
[ -z "$changelogEntry" ] && changelogEntry="Release v$newVersion"
if [ -n "$CI_PR_DESCRIPTION" ]; then
changelogFull="- $changelogEntry
$CI_PR_DESCRIPTION"
else
changelogFull="- $changelogEntry"
fi
# bump files
awk -v v="$newVersion" '/"version"/ { sub(/[0-9]+\.[0-9]+\.[0-9]+/, v) } { print }' package.json > package.json.tmp && mv package.json.tmp package.json
@@ -24,18 +57,18 @@ awk -v v="$newVersion" '/^ tag:/ { $0 = " tag: " v }; { print }' helm/kestrelo
# changelog
new="## [$newVersion] - $(date +%Y-%m-%d)
### Changed
- $changelogEntry
$changelogFull
"
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# Create CHANGELOG.md if missing (first release); otherwise prepend new entry to existing content.
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md || true; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# git
git config user.email "ci@kestrelos" && git config user.name "CI"
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
git commit -m "release v$newVersion [skip ci]"
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
git tag "v$newVersion"
# artifact for kaniko (tag list)
# artifact for docker (tag list)
printf '%s\n%s\n' "$newVersion" "latest" > .tags
retry() { n=0; while ! "$@"; do n=$((n+1)); [ $n -ge 3 ] && return 1; sleep 2; done; }
retry git push "$url" HEAD:main "v$newVersion"
@@ -43,7 +76,7 @@ retry git push "$url" HEAD:main "v$newVersion"
# gitea release
body="## Changelog
### Changed
- $changelogEntry
$changelogFull
## Installation
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
+19
View File
@@ -0,0 +1,19 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getAlprCameras, parseBbox } from '../utils/alpr.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
let bbox
try {
bbox = parseBbox(getQuery(event))
}
catch (error) {
throw createError({
statusCode: error?.statusCode || 400,
message: error?.message || 'invalid bbox',
})
}
const db = await getDb()
return getAlprCameras(db, bbox)
})
+1 -1
View File
@@ -1,3 +1,3 @@
import { getAuthConfig } from '../../utils/authConfig.js'
import { getAuthConfig } from '../../utils/oidc.js'
export default defineEventHandler(() => getAuthConfig())
+5 -1
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()
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAuthConfig } from '../../../utils/authConfig.js'
import {
getAuthConfig,
getOidcConfig,
getOidcRedirectUri,
createOidcParams,
+4 -1
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()
+4 -1
View File
@@ -5,7 +5,10 @@ import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
const [db, sessions] = await Promise.all([
getDb(),
getActiveSessions(),
])
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 }
+8
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) }
})
+60
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 }
}
})
+45
View File
@@ -0,0 +1,45 @@
import { createEventStream } from 'h3'
import { requireAuth } from '../../utils/authHelpers.js'
import { getActiveEntitiesInBbox } from '../../utils/cotStore.js'
import { registerSubscriber } from '../../utils/cotSubscribers.js'
import { getCotSnapshotOpts } from '../../utils/cotSnapshot.js'
import { COT_SSE_HEARTBEAT_MS } from '../../utils/constants.js'
import { parseBboxParam, parseLayersParam } from '../../utils/cotEntityUtils.js'
import { scheduleTrackingFeedRefresh } from '../../utils/trackingFeed.js'
export default defineEventHandler((event) => {
requireAuth(event)
const query = getQuery(event)
const bbox = parseBboxParam(typeof query.bbox === 'string' ? query.bbox : undefined)
const layers = parseLayersParam(typeof query.layers === 'string' ? query.layers : undefined)
const snapshotOpts = getCotSnapshotOpts()
const stream = createEventStream(event)
const push = (eventName, data) => stream.push({ event: eventName, data })
const sendSnapshot = async () => {
const entities = await getActiveEntitiesInBbox(bbox, { ...snapshotOpts, layers })
await push('snapshot', JSON.stringify({ entities }))
}
const unregister = registerSubscriber({ bbox, layers, push })
let heartbeat
stream.onClosed(async () => {
clearInterval(heartbeat)
unregister()
scheduleTrackingFeedRefresh()
})
void (async () => {
scheduleTrackingFeedRefresh()
await sendSnapshot()
heartbeat = setInterval(() => {
push('heartbeat', '{}').catch(() => {})
}, COT_SSE_HEARTBEAT_MS)
})()
return stream.send()
})
+24
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 }
}
})
+12 -10
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)
})
})
+15 -21
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)
+22 -19
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 }
})
})
+40 -14
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
}
})
})
+30 -26
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,
}
})
})
@@ -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) {
@@ -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' })
}
+41 -25
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,
}
})
+31 -20
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
})
})
@@ -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
})
+8 -1
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])
+9 -2
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)
+26 -1
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)
+26
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 }
})
+1 -1
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' })
+12 -14
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
+16 -14
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
})
})
+41 -40
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
})
})
+1 -1
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
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, type: parsed.eventType }).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()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { registerCleanup } from '../utils/shutdown.js'
import { onCotChange, pruneStaleEntities } from '../utils/cotStore.js'
import { getCotSnapshotOpts } from '../utils/cotSnapshot.js'
import {
notifySubscribersForEntity,
notifySubscribersRemove,
} from '../utils/cotSubscribers.js'
import { COT_PRUNE_INTERVAL_MS } from '../utils/constants.js'
let pruneTimer = null
let offChange = () => {}
export default defineNitroPlugin(() => {
offChange = onCotChange((changeEvent, payload) => {
if (changeEvent === 'update' && payload?.entity) {
notifySubscribersForEntity('update', { entity: payload.entity }, payload.entity).catch(() => {})
}
else if (changeEvent === 'remove' && payload?.id) {
notifySubscribersRemove(payload.id).catch(() => {})
}
})
pruneTimer = setInterval(() => {
pruneStaleEntities(getCotSnapshotOpts()).catch(() => {})
}, COT_PRUNE_INTERVAL_MS)
registerCleanup(() => {
offChange()
if (pruneTimer) clearInterval(pruneTimer)
pruneTimer = null
})
})
+7
View File
@@ -0,0 +1,7 @@
import { registerCleanup } from '../utils/shutdown.js'
import { startTrackingFeed, stopTrackingFeed } from '../utils/trackingFeed.js'
export default defineNitroPlugin(() => {
startTrackingFeed()
registerCleanup(stopTrackingFeed)
})
+32 -2
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', () => {
+9 -1
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' }
})
+263
View File
@@ -0,0 +1,263 @@
import { cameraToFeature, featureCollection } from './alprGeo.js'
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
const MAX_BBOX_DEGREES = 0.5
const CACHE_TTL_MS = 86_400_000
const TILE_SCALE = 10
function httpError(statusCode, message) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
/**
* @param {unknown} tags
* @returns {Record<string, string>} String OSM tags only
*/
function normalizeTags(tags) {
if (!tags || typeof tags !== 'object') return {}
return Object.fromEntries(
Object.entries(/** @type {Record<string, unknown>} */ (tags))
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, /** @type {string} */ (v)]),
)
}
/**
* @param {unknown} element
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
*/
export function parseOverpassElement(element) {
if (!element || typeof element !== 'object') return null
const el = /** @type {Record<string, unknown>} */ (element)
if (el.type !== 'node' || typeof el.id !== 'number') return null
const lat = Number(el.lat)
const lng = Number(el.lon ?? el.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const tags = normalizeTags(el.tags)
const directionRaw = tags['camera:direction'] ?? tags.direction
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: el.id,
lat,
lng,
manufacturer: tags.manufacturer ?? tags.brand ?? null,
direction: Number.isFinite(direction) ? direction : null,
fov: Number.isFinite(fov) ? fov : null,
tags,
}
}
export function parseBbox(query) {
const south = Number(query.south)
const west = Number(query.west)
const north = Number(query.north)
const east = Number(query.east)
if (![south, west, north, east].every(Number.isFinite)) {
throw httpError(400, 'south, west, north, east required')
}
if (south >= north || west >= east) {
throw httpError(400, 'invalid bbox')
}
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
}
return { south, west, north, east }
}
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
export function tileKeysForBbox(bbox) {
const latMin = Math.floor(bbox.south * TILE_SCALE)
const latMax = Math.floor(bbox.north * TILE_SCALE)
const lngMin = Math.floor(bbox.west * TILE_SCALE)
const lngMax = Math.floor(bbox.east * TILE_SCALE)
const keys = []
for (let lat = latMin; lat <= latMax; lat++) {
for (let lng = lngMin; lng <= lngMax; lng++) {
keys.push(`${lat}:${lng}`)
}
}
return keys
}
export function buildBboxQuery(bbox) {
const { south, west, north, east } = bbox
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
}
export function buildGlobalQuery() {
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
}
function rowToCamera(row, source) {
const tags = (() => {
try {
return JSON.parse(row.tags)
}
catch {
return {}
}
})()
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: row.osm_id,
lat: row.lat,
lng: row.lng,
manufacturer: row.manufacturer ?? null,
direction: row.direction ?? null,
fov: Number.isFinite(fov) ? fov : null,
tags,
source,
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNode(db, camera) {
const now = new Date().toISOString()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(osm_id) DO UPDATE SET
lat = excluded.lat,
lng = excluded.lng,
manufacturer = excluded.manufacturer,
direction = excluded.direction,
tags = excluded.tags,
fetched_at = excluded.fetched_at`,
[
camera.osmId,
camera.lat,
camera.lng,
camera.manufacturer,
camera.direction,
JSON.stringify(camera.tags),
now,
],
)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNodesBatch(db, cameras) {
if (!cameras.length) return
await db.run('BEGIN TRANSACTION')
try {
for (const camera of cameras) {
await upsertAlprNode(db, camera)
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function markTilesFetched(db, keys) {
if (!keys.length) return
const now = new Date().toISOString()
await db.run('BEGIN TRANSACTION')
try {
for (const key of keys) {
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function tilesAreFresh(db, keys) {
if (!keys.length) return false
const placeholders = keys.map(() => '?').join(',')
const rows = await db.all(
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
keys,
)
if (rows.length !== keys.length) return false
const cutoff = Date.now() - CACHE_TTL_MS
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getCachedAlprNodes(db, bbox) {
const rows = await db.all(
`SELECT osm_id, lat, lng, manufacturer, direction, tags
FROM alpr_nodes
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
[bbox.south, bbox.north, bbox.west, bbox.east],
)
return rows.map(row => rowToCamera(row, 'cache'))
}
export async function fetchOverpass(query) {
const res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(120_000),
})
if (!res.ok) {
throw httpError(502, `Overpass error: ${res.status}`)
}
const data = await res.json()
const elements = Array.isArray(data?.elements) ? data.elements : []
return elements.map(parseOverpassElement).filter(Boolean)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function fetchAndCacheBbox(db, bbox, tileKeys) {
const live = await fetchOverpass(buildBboxQuery(bbox))
await upsertAlprNodesBatch(db, live)
await markTilesFetched(db, tileKeys)
return live.map(c => ({ ...c, source: 'live' }))
}
const refreshInFlight = new Map()
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
function refreshBboxInBackground(db, bbox, tileKeys) {
const key = tileKeys.join('|')
if (refreshInFlight.has(key)) return
const job = fetchAndCacheBbox(db, bbox, tileKeys)
.catch(() => {})
.finally(() => refreshInFlight.delete(key))
refreshInFlight.set(key, job)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getAlprCameras(db, bbox) {
const tileKeys = tileKeysForBbox(bbox)
const cached = await getCachedAlprNodes(db, bbox)
if (await tilesAreFresh(db, tileKeys)) {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
if (cached.length > 0) {
refreshBboxInBackground(db, bbox, tileKeys)
return featureCollection(cached.map(cameraToFeature), 'cache')
}
try {
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
return featureCollection(live.map(cameraToFeature), 'live')
}
catch {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function importAllAlprNodes(db) {
const cameras = await fetchOverpass(buildGlobalQuery())
await upsertAlprNodesBatch(db, cameras)
return cameras.length
}
+175
View File
@@ -0,0 +1,175 @@
const ATTRIBUTION = '© OpenStreetMap contributors'
/** Longest match first — Flock, Genetec, Leonardo, etc. */
const KNOWN_ALPR_MODELS = [
'Falcon Flex',
'AutoVu CR-H2',
'AutoVu Sharp',
'UnicamVELOCITY3',
'Falcon',
'Sparrow',
'Raven',
'Condor',
'Wing',
'Pelican',
]
const GENERIC_CAMERA_NAMES = new Set([
'flock alpr camera',
'flock alpr cameera',
'flock camera',
'flock alpr',
'flock',
'flock camera',
'automatic license plate reader (alpr)',
'alpr camera',
'alpr',
])
const SKIP_EXTRA_TAGS = new Set([
'surveillance:type',
'man_made',
'camera:direction',
'direction',
'camera:angle',
'surveillance:angle',
'manufacturer',
'brand',
'model',
'operator',
'name',
'ref',
'surveillance',
'camera:type',
'description',
'note',
'colour',
'color',
'manufacturer:wikidata',
'model:wikidata',
'operator:wikidata',
])
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** @param {string} value */
export function normalizeModelName(value) {
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
for (const model of KNOWN_ALPR_MODELS) {
if (model.toLowerCase() === lower) return model
}
return trimmed.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
).join(' ')
}
/** @param {string} text */
function matchKnownModel(text) {
for (const model of KNOWN_ALPR_MODELS) {
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
if (re.test(text)) return model
}
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
if (flock) {
const fragment = flock[1].trim()
if (!fragment || /^safety$/i.test(fragment)) return null
return matchKnownModel(fragment)
}
return null
}
/** @param {Record<string, string>} tags */
export function inferModelFromTags(tags) {
const t = tags ?? {}
if (t.model?.trim()) return normalizeModelName(t.model)
for (const key of ['name', 'description', 'note']) {
const value = t[key]
if (!value?.trim()) continue
const model = matchKnownModel(value)
if (model) return model
}
return null
}
/** @param {string | null | undefined} name */
export function isGenericCameraName(name) {
if (!name?.trim()) return false
const normalized = name.trim().toLowerCase()
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
return false
}
/** @param {Record<string, string>} tags */
export function displayNameFromTags(tags) {
const name = tags?.name?.trim()
if (!name || isGenericCameraName(name)) return null
if (matchKnownModel(name)) return null
return name
}
/** @param {Record<string, string>} tags */
export function identifyingProperties(tags, manufacturer = null) {
const t = tags ?? {}
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
const model = inferModelFromTags(t)
const name = displayNameFromTags(t)
/** @type {Record<string, string | boolean>} */
const props = {}
if (mfr) props.manufacturer = mfr
if (model) props.model = model
if (mfr && !model) props.modelUnknown = true
if (t.brand && t.brand !== mfr) props.brand = t.brand
if (t.operator) props.operator = t.operator
if (name) props.name = name
if (t.ref) props.ref = t.ref
if (t.surveillance) props.surveillance = t.surveillance
if (t['camera:type']) props.cameraType = t['camera:type']
if (t.description) props.description = t.description
if (t.note) props.note = t.note
const colour = t.colour ?? t.color
if (colour) props.colour = colour
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
const extra = {}
for (const [key, value] of Object.entries(t)) {
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
extra[key] = value
}
if (Object.keys(extra).length) props.tags = extra
return props
}
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
export function cameraToFeature(camera) {
return {
type: 'Feature',
id: camera.osmId,
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
properties: {
osmId: camera.osmId,
direction: camera.direction,
fov: camera.fov,
...identifyingProperties(camera.tags, camera.manufacturer),
},
}
}
/** @param {ReturnType<typeof cameraToFeature>[]} features */
export function featureCollection(features, source) {
return {
type: 'FeatureCollection',
features,
attribution: ATTRIBUTION,
source,
}
}
export { ATTRIBUTION }
+47
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()
}
-5
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 } })
}
+25
View File
@@ -8,3 +8,28 @@ 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/alpr',
'/api/cot',
'/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 + '/'))
}
-23
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 + '/'))
}
-26
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`)
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Application constants with environment variable support.
*/
// CoT / tracking (fixed defaults — not env-configurable)
export const COT_TTL_MS = 90_000
/** @deprecated Use COT_TTL_MS */
export const COT_ENTITY_TTL_MS = COT_TTL_MS
export const COT_OSINT_TTL_MS = 30_000
export const COT_PRUNE_INTERVAL_MS = 15_000
export const COT_SSE_HEARTBEAT_MS = 15_000
export const OPENSKY_CACHE_MS = 5_000
export const TRACKING_FEED_DEBOUNCE_MS = 500
export const COT_TAK_FILTER_BBOX = false
export const COT_SSE_MAX_ENTITIES = 2000
export const MAX_OPENSKY_BBOX_DEGREES = 10
// 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 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
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)
}
+191
View File
@@ -0,0 +1,191 @@
/**
* CoT entity helpers: filters and OSINT CoT mapping.
*/
/**
* @param {string} id
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
*/
export function inferSourceFromId(id) {
if (typeof id !== 'string') return 'tak'
if (id.startsWith('ICAO.')) return 'adsb'
if (id.startsWith('MMSI.')) return 'ais'
return 'tak'
}
/**
* @param {string} [type]
* @returns {'air' | 'surface' | 'ground'} CoT display category.
*/
export function cotCategoryFromType(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/**
* @param {{ lat: number, lng: number }} point
* @param {{ west: number, south: number, east: number, north: number }} bbox
*/
export function isInBbox(point, bbox) {
if (!point || !bbox) return false
const { lat, lng } = point
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
}
/**
* @param {Set<string> | string[] | undefined} layers
* @param {{ type?: string, source?: string }} entity
*/
export function matchesLayerFilter(layers, entity) {
if (!layers || layers.size === 0) return true
const category = cotCategoryFromType(entity.type)
if (layers.has('air') && category === 'air') return true
if (layers.has('surface') && category === 'surface') return true
if (layers.has('ground') && category === 'ground') return true
return false
}
/** OpenSky emitter category → MilStd CoT air type. */
function openSkyCategoryToType(category) {
if (category === 8) return 'a-f-A-C-H' // rotorcraft
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
return 'a-f-A-C-F'
}
/** OpenSky state vector → CoT upsert. */
export function openSkyStateToCot(state) {
if (!Array.isArray(state) || state.length < 11) return null
const icao24 = String(state[0] ?? '').trim().toLowerCase()
if (!icao24) return null
const lat = Number(state[6])
const lng = Number(state[5])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
const category = Number(state[17])
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
const heading = Number(state[10])
const speed = Number(state[9])
const altitude = Number(state[7])
const verticalRate = Number(state[11])
const onGround = state[8] === true
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
return {
id: `ICAO.${icao24}`,
lat,
lng,
label: callsign || icao24.toUpperCase(),
type,
source: 'adsb',
icao: icao24,
originCountry: originCountry || undefined,
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
altitude: Number.isFinite(altitude) ? altitude : undefined,
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
onGround: onGround || undefined,
squawk: squawk && squawk !== '0000' ? squawk : undefined,
}
}
/** AISStream position report → CoT upsert. */
export function aisStreamMessageToCot(message) {
if (!message || typeof message !== 'object') return null
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
const msg = /** @type {Record<string, unknown>} */ (message.Message)
if (!msg || typeof msg !== 'object') return null
const report = /** @type {Record<string, unknown>} */ (
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
)
if (!report || typeof report !== 'object') return null
const mmsi = Number(meta?.MMSI ?? report.UserID)
if (!Number.isFinite(mmsi)) return null
const lat = Number(report.Latitude)
const lng = Number(report.Longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
const heading = Number(report.Cog ?? report.TrueHeading)
const speed = Number(report.Sog)
return {
id: `MMSI.${mmsi}`,
lat,
lng,
label: shipName || `MMSI ${mmsi}`,
type: 'a-f-S-C',
source: 'ais',
mmsi: String(mmsi),
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
}
}
/**
* Union of subscriber bboxes.
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
*/
export function unionBboxes(boxes) {
let west = Infinity
let south = Infinity
let east = -Infinity
let north = -Infinity
let has = false
for (const bbox of boxes) {
if (!bbox) continue
has = true
west = Math.min(west, bbox.west)
south = Math.min(south, bbox.south)
east = Math.max(east, bbox.east)
north = Math.max(north, bbox.north)
}
return has ? { west, south, east, north } : null
}
/**
* Shrink bbox to max span (degrees per axis), centered on midpoint.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {number} maxDegrees
*/
export function clampBbox(bbox, maxDegrees) {
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
const latSpan = bbox.north - bbox.south
const lngSpan = bbox.east - bbox.west
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
const latMid = (bbox.north + bbox.south) / 2
const lngMid = (bbox.east + bbox.west) / 2
const half = maxDegrees / 2
return {
south: latMid - half,
north: latMid + half,
west: lngMid - half,
east: lngMid + half,
}
}
/**
* @param {string | undefined} raw
* @returns {Set<string>} Enabled layer names.
*/
export function parseLayersParam(raw) {
if (!raw || typeof raw !== 'string') {
return new Set(['air', 'surface', 'ground'])
}
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (parts.length === 0 || parts.includes('none')) return new Set()
return new Set(parts)
}
/**
* @param {string | undefined} raw
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
*/
export function parseBboxParam(raw) {
if (!raw || typeof raw !== 'string') return null
const parts = raw.split(',').map(s => Number(s.trim()))
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
const [west, south, east, north] = parts
if (south > north || west > east) return null
return { west, south, east, north }
}
+151
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
}
}
+15
View File
@@ -0,0 +1,15 @@
import {
COT_OSINT_TTL_MS,
COT_SSE_MAX_ENTITIES,
COT_TAK_FILTER_BBOX,
COT_TTL_MS,
} from './constants.js'
export function getCotSnapshotOpts() {
return {
ttlMs: COT_TTL_MS,
osintTtlMs: COT_OSINT_TTL_MS,
takFilterBbox: COT_TAK_FILTER_BBOX,
maxEntities: COT_SSE_MAX_ENTITIES,
}
}
+73
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
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* In-memory CoT store (TAK, ADS-B, AIS).
*/
import { acquire } from './asyncLock.js'
import { COT_OSINT_TTL_MS, COT_TTL_MS } from './constants.js'
import { inferSourceFromId, isInBbox, matchesLayerFilter } from './cotEntityUtils.js'
const entities = new Map()
/** @type {Set<(event: string, payload: unknown) => void>} */
const listeners = new Set()
/**
* @param {(event: string, payload: unknown) => void} fn
* @returns {() => void} Unsubscribe function.
*/
export function onCotChange(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function emitChange(event, payload) {
for (const fn of listeners) {
try {
fn(event, payload)
}
catch {
/* ignore listener errors */
}
}
}
function pickOptionalNumber(value, fallback) {
if (value === undefined || value === null) return fallback
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
/**
* @param {Record<string, unknown>} entity
*/
function toSnapshot(entity) {
return {
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
source: entity.source ?? 'tak',
heading: entity.heading,
speed: entity.speed,
altitude: entity.altitude,
verticalRate: entity.verticalRate,
onGround: entity.onGround,
originCountry: entity.originCountry,
icao: entity.icao,
mmsi: entity.mmsi,
squawk: entity.squawk,
updatedAt: entity.updatedAt,
}
}
/**
* @param {Record<string, unknown>} entity
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function entityTtlMs(entity, opts) {
const source = entity.source
if (source === 'adsb' || source === 'ais') {
return opts.osintTtlMs ?? COT_OSINT_TTL_MS
}
return opts.ttlMs ?? COT_TTL_MS
}
function isEntityExpired(entity, now, opts) {
return now - entity.updatedAt > entityTtlMs(entity, opts)
}
/**
* 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, source?: string, heading?: number, speed?: number, altitude?: number }} parsed
* @param {{ silent?: boolean }} [options]
*/
export async function updateFromCot(parsed, options = {}) {
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
let snapshot = null
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 ?? ''))
const explicitSource = parsed.source
const source = explicitSource === 'adsb' || explicitSource === 'ais' || explicitSource === 'tak'
? explicitSource
: inferSourceFromId(parsed.id)
const stored = {
id: parsed.id,
lat,
lng,
label,
type,
source,
heading: pickOptionalNumber(parsed.heading, existing?.heading),
speed: pickOptionalNumber(parsed.speed, existing?.speed),
altitude: pickOptionalNumber(parsed.altitude, existing?.altitude),
verticalRate: pickOptionalNumber(parsed.verticalRate, existing?.verticalRate),
onGround: typeof parsed.onGround === 'boolean' ? parsed.onGround : existing?.onGround,
originCountry: typeof parsed.originCountry === 'string' ? parsed.originCountry : existing?.originCountry,
icao: typeof parsed.icao === 'string' ? parsed.icao : existing?.icao,
mmsi: typeof parsed.mmsi === 'string' ? parsed.mmsi : existing?.mmsi,
squawk: typeof parsed.squawk === 'string' ? parsed.squawk : existing?.squawk,
updatedAt: now,
}
entities.set(parsed.id, stored)
snapshot = toSnapshot(stored)
})
if (snapshot && !options.silent) emitChange('update', { entity: snapshot })
}
/**
* @param {number} now
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function pruneExpired(now, opts) {
const expired = []
for (const entity of entities.values()) {
if (isEntityExpired(entity, now, opts)) expired.push(entity.id)
}
for (const id of expired) {
entities.delete(id)
emitChange('remove', { id })
}
}
/**
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function pruneStaleEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
await acquire('cot-prune', async () => {
pruneExpired(Date.now(), { ttlMs, osintTtlMs })
})
}
/**
* @param {Record<string, unknown>} entity
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
* @param {boolean} takFilterBbox
*/
function passesBboxFilter(entity, bbox, takFilterBbox) {
if (!bbox) return true
const inBox = isInBbox(entity, bbox)
if (entity.source === 'tak' && !takFilterBbox) return true
return inBox
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function getActiveEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, { ttlMs, osintTtlMs })
return [...entities.values()].map(toSnapshot)
})
}
/**
* Active entities filtered by viewport bbox and layer set.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {{ ttlMs?: number, osintTtlMs?: number, layers?: Set<string>, takFilterBbox?: boolean, maxEntities?: number }} [opts]
*/
export async function getActiveEntitiesInBbox(bbox, opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
const layers = opts.layers
const takFilterBbox = Boolean(opts.takFilterBbox)
const ttlOpts = { ttlMs, osintTtlMs }
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, ttlOpts)
const active = []
for (const entity of entities.values()) {
if (isEntityExpired(entity, now, ttlOpts)) continue
const snap = toSnapshot(entity)
if (!passesBboxFilter(snap, bbox, takFilterBbox)) continue
if (!matchesLayerFilter(layers, snap)) continue
active.push(snap)
}
const maxEntities = opts.maxEntities
if (maxEntities != null && active.length > maxEntities) {
active.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
active.length = maxEntities
}
return active
})
}
/** Clear store (tests only). */
export function clearCotStore() {
entities.clear()
}
+70
View File
@@ -0,0 +1,70 @@
/** SSE subscriber registry; bbox union drives OSINT feeds. */
import { getActiveEntitiesInBbox } from './cotStore.js'
import { isInBbox, matchesLayerFilter, unionBboxes } from './cotEntityUtils.js'
/** @typedef {{ west: number, south: number, east: number, north: number }} Bbox */
/** @typedef {(event: string, data: string) => Promise<void> | void} PushFn */
/** @type {Map<string, { bbox: Bbox | null, layers: Set<string>, push: PushFn }>} */
const subscribers = new Map()
let nextId = 1
/**
* @param {{ bbox: Bbox | null, layers: Set<string>, push: PushFn }} sub
* @returns {() => void} Unregister function.
*/
export function registerSubscriber(sub) {
const id = String(nextId++)
subscribers.set(id, sub)
return () => subscribers.delete(id)
}
/** @returns {Bbox | null} Union of all subscriber bboxes. */
export function getSubscriberBboxUnion() {
return unionBboxes([...subscribers.values()].map(s => s.bbox))
}
export function getSubscriberCount() {
return subscribers.size
}
export function clearSubscribers() {
subscribers.clear()
}
export async function notifySubscribersForEntity(event, payload, entity) {
const data = JSON.stringify(payload)
const tasks = []
for (const sub of subscribers.values()) {
if (sub.bbox && !isInBbox(entity, sub.bbox)) continue
if (!matchesLayerFilter(sub.layers, entity)) continue
tasks.push(Promise.resolve(sub.push(event, data)))
}
await Promise.all(tasks)
}
export async function notifySubscribersRemove(id) {
const data = JSON.stringify({ id })
await Promise.all(
[...subscribers.values()].map(sub => Promise.resolve(sub.push('remove', data))),
)
}
/**
* Push a filtered snapshot to each active SSE subscriber.
* @param {{ ttlMs?: number, osintTtlMs?: number, takFilterBbox?: boolean, maxEntities?: number }} snapshotOpts
*/
export async function broadcastSubscriberSnapshots(snapshotOpts) {
const tasks = []
for (const sub of subscribers.values()) {
tasks.push((async () => {
const entities = await getActiveEntitiesInBbox(sub.bbox, {
...snapshotOpts,
layers: sub.layers,
})
await sub.push('snapshot', JSON.stringify({ entities }))
})())
}
await Promise.all(tasks)
}
+236 -19
View File
@@ -1,13 +1,14 @@
import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
import { randomBytes, randomUUID } from 'node:crypto'
import { createRequire } from 'node:module'
import { promisify } from 'node:util'
import { bootstrapAdmin } from './bootstrap.js'
import { hashPassword } from './password.js'
import { registerCleanup } from './shutdown.js'
const require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3')
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const { DatabaseSync } = requireFromRoot('node:sqlite')
const SCHEMA_VERSION = 3
const SCHEMA_VERSION = 6
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -58,6 +59,20 @@ const SCHEMA = {
source_type TEXT NOT NULL DEFAULT 'mjpeg',
config TEXT
)`,
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
osm_id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lng REAL NOT NULL,
manufacturer TEXT,
direction INTEGER,
tags TEXT NOT NULL,
fetched_at TEXT NOT NULL
)`,
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
tile_key TEXT PRIMARY KEY,
fetched_at TEXT NOT NULL
)`,
}
const getDbPath = () => {
@@ -111,6 +126,25 @@ 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 migrateToV5 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
}
const migrateToV6 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_tiles)
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -122,6 +156,18 @@ 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)
}
if (version < 5) {
await migrateToV5(run, all)
await setSchemaVersion(run, 5)
}
if (version < 6) {
await migrateToV6(run, all)
await setSchemaVersion(run, 6)
}
}
const initDb = async (db, run, all, get) => {
@@ -131,7 +177,6 @@ const initDb = async (db, run, all, get) => {
catch {
// WAL not supported (e.g., network filesystem)
}
db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS)
await run(SCHEMA.schema_version)
await run(SCHEMA.users)
@@ -139,43 +184,215 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.sessions)
await run(SCHEMA.pois)
await run(SCHEMA.devices)
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
await run(SCHEMA.alpr_tiles)
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 (?, ?, ?, ?, ?, ?, ?, ?)',
[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() {
if (dbInstance) return dbInstance
const db = new sqlite3.Database(getDbPath(), (err) => {
if (err) {
const sqliteDb = (() => {
try {
return new DatabaseSync(getDbPath(), { timeout: DB_BUSY_TIMEOUT_MS })
}
catch (err) {
console.error('[db] Failed to open database:', err.message)
throw err
}
})
})()
const run = promisify(db.run.bind(db))
const all = promisify(db.all.bind(db))
const get = promisify(db.get.bind(db))
const run = (sql, params = []) =>
new Promise((resolve, reject) => {
const stmt = sqliteDb.prepare(sql)
try {
stmt.run(...params)
resolve()
}
catch (error) {
reject(error)
}
finally {
try {
stmt.finalize()
}
catch {
// ignore finalize errors
}
}
})
const all = (sql, params = []) =>
new Promise((resolve, reject) => {
const stmt = sqliteDb.prepare(sql)
try {
const rows = stmt.all(...params)
resolve(rows)
}
catch (error) {
reject(error)
}
finally {
try {
stmt.finalize()
}
catch {
// ignore finalize errors
}
}
})
const get = (sql, params = []) =>
new Promise((resolve, reject) => {
const stmt = sqliteDb.prepare(sql)
try {
const row = stmt.get(...params)
resolve(row)
}
catch (error) {
reject(error)
}
finally {
try {
stmt.finalize()
}
catch {
// ignore finalize errors
}
}
})
const wrappedDb = {
close(callback) {
try {
sqliteDb.close()
if (typeof callback === 'function') callback(null)
}
catch (error) {
if (typeof callback === 'function') callback(error)
else throw error
}
},
}
try {
await initDb(db, run, all, get)
await initDb(wrappedDb, run, all, get)
}
catch (error) {
db.close()
wrappedDb.close()
console.error('[db] Database initialization failed:', error.message)
throw error
}
dbInstance = { db, run, all, get }
dbInstance = { db: wrappedDb, run, all, get }
registerCleanup(async () => {
if (dbInstance) {
try {
dbInstance.db.close()
}
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 {
dbInstance.db.close((err) => {
if (err) console.error('[db] Error closing database:', err.message)
})
dbInstance.db.close()
}
catch (error) {
console.error('[db] Error closing database:', error.message)
+89 -55
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
})
}

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