major: kestrel is now a tak server #6

Merged
keligrubb merged 6 commits from atak-compat into main 2026-02-17 16:41:42 +00:00
96 changed files with 5767 additions and 500 deletions
Showing only changes of commit b0e8dd7ad9 - Show all commits

View File

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

View File

@@ -2,6 +2,8 @@
Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream.
![KestrelOS map UI](docs/screenshot.png)
## Stack
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
@@ -50,7 +52,11 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
**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.
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
## Scripts
@@ -58,13 +64,18 @@ See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
- `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.
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
@@ -72,7 +83,7 @@ See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
```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)

View File

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

View File

@@ -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,7 +248,7 @@ 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
})
@@ -274,15 +274,15 @@ async function setupWebRTC() {
}
function cleanup() {
if (consumer) {
consumer.close()
consumer = null
if (consumer.value) {
consumer.value.close()
consumer.value = null
}
if (recvTransport) {
recvTransport.close()
recvTransport = null
if (recvTransport.value) {
recvTransport.value.close()
recvTransport.value = null
}
device = null
device.value = null
if (videoRef.value) {
videoRef.value.srcObject = null
}
@@ -308,7 +308,7 @@ watch(
watch(
() => props.session?.hasStream,
(hasStream) => {
if (hasStream && props.session?.id && !device) {
if (hasStream && props.session?.id && !device.value) {
setupWebRTC()
}
else if (!hasStream) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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…')
@@ -281,7 +281,7 @@ async function startSharing() {
}
try {
await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition(
locationWatchId.value = navigator.geolocation.watchPosition(
(pos) => {
resolve(pos)
},
@@ -332,9 +332,9 @@ async function startSharing() {
}
catch (e) {
if (e?.statusCode === 404) {
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationIntervalId = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
locationIntervalId.value = null
}
sharing.value = false
if (!locationUpdate404Logged) {
@@ -350,7 +350,7 @@ async function startSharing() {
}
await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000)
locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
}
catch (e) {
starting.value = false
@@ -363,23 +363,23 @@ async function startSharing() {
}
function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId)
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId.value)
}
locationWatchId = null
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationWatchId.value = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
}
locationIntervalId = null
if (producer) {
producer.close()
producer = null
locationIntervalId.value = null
if (producer.value) {
producer.value.close()
producer.value = null
}
if (sendTransport) {
sendTransport.close()
sendTransport = null
if (sendTransport.value) {
sendTransport.value.close()
sendTransport.value = null
}
device = null
device.value = null
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null

View File

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

20
docs/README.md Normal file
View File

@@ -0,0 +1,20 @@
# KestrelOS documentation
KestrelOS is a Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
## Documentation index
| Topic | Description |
|-------|-------------|
| [Installation](installation.md) | Install via npm, Docker, or Helm (Kubernetes) |
| [Authentication](auth.md) | Local login, OIDC config, and sign up |
| [Map and cameras](map-and-cameras.md) | Using the map, adding and viewing cameras (IPTV, ALPR, CCTV, NVR, etc.) |
| [ATAK and iTAK](atak-itak.md) | Connect ATAK (Android) and iTAK (iOS) to KestrelOS as a TAK Server |
| [Share live](live-streaming.md) | Add your mobile device as a live camera (WebRTC) |
## Quick links
- **First run**: [Installation](installation.md) → [Authentication](auth.md) (bootstrap admin or OIDC).
- **Add a camera**: [Map and cameras](map-and-cameras.md) (API or future UI).
- **Stream from phone**: [Share live](live-streaming.md) (HTTPS required).
- **Use ATAK/iTAK**: [ATAK and iTAK](atak-itak.md) (Server connection, port 8089 for CoT).

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

@@ -0,0 +1,188 @@
# ATAK and iTAK usage
KestrelOS can act as a **TAK Server**: ATAK (Android) and iTAK (iOS) devices connect to it the same way they connect to any TAK Server. No plugins are required. Once connected, devices relay Cursor on Target (CoT) to each other so team members see each other on the ATAK/iTAK map, and their positions appear on the KestrelOS web map.
---
## Overview
- **ATAK** — Android Team Awareness Kit.
- **iTAK** — iOS version; uses the same TAK Server connection flow.
- **KestrelOS** — Web app and API use **port 3000**. CoT (TAK) uses **port 8089** (TLS when `.dev-certs/` or `COT_SSL_*` are set, else plain TCP). In ATAK/iTAK you connect to host and port **8089**; enable SSL if the server uses TLS.
You add KestrelOS as a **Server** connection in ATAK or iTAK (host + port **8089**). If authentication is enabled, you use your **KestrelOS username** and a **password** (local users: login password; OIDC users: ATAK password set in the web app).
---
## ATAK (Android)
### 1. Add the server
1. Open **ATAK**.
2. Go to **Settings** (or **Configuration**) → **Network****Connections** (or **Server Connections**).
3. Add a new connection:
- **Type:** TAK Server / Server.
- **Host:** Your KestrelOS hostname or IP (e.g. `kestrelos.example.com` or `192.168.1.10`).
- **Port:** **8089** (CoT port). Use SSL if the server runs with TLS (e.g. `.dev-certs/` or production cert).
4. If your KestrelOS instance requires authentication:
- Enable **Use Authentication** (or equivalent).
- **Username:** Your KestrelOS identifier (email or username).
- **Password:**
- **Local account:** Your KestrelOS login password.
- **OIDC account:** The **ATAK password** you set under **Account** in the KestrelOS web app (you must set it once before connecting).
5. Save and connect.
### 2. Verify
- After connecting, your device and other connected ATAK/iTAK devices should appear on the ATAK map.
- In KestrelOS, open the **Map** in a browser; you should see amber markers for connected TAK devices. They disappear after about 90 seconds without updates.
---
## iTAK (iOS)
### 1. Add the server
**Option A — Scan QR code (easiest)**
In KestrelOS, open **Settings** and find the **TAK Server (ATAK / iTAK)** section. Scan the QR code with iTAK (Add Connection → Scan QR). iTAK will add the server; youll be prompted for your KestrelOS username and password. If the server uses a self-signed certificate, import the trust store first (see [Self-signed certificate: import the trust store](#2-self-signed-certificate-import-the-trust-store-required-for-ssl)).
**Option B — Manual**
1. Open **iTAK**.
2. Go to **Settings****Network** / **Connections** (wording may vary by version).
3. Add a **TAK Server** (or **Server**) connection:
- **Host:** KestrelOS hostname or IP.
- **Port:** **8089** (CoT port). Use SSL when the server uses TLS.
4. If authentication is required:
- Turn on **Use Authentication**.
- **Username:** Your KestrelOS identifier.
- **Password:** KestrelOS login password (local) or ATAK password (OIDC; set in KestrelOS **Account**).
5. Save and connect.
### 2. Self-signed certificate (required for SSL)
If KestrelOS uses a **self-signed certificate** (e.g. `.dev-certs/`), the client must trust it or the TLS handshake never completes. Youll see “Error authenticating with the server” and **no `[cot]` logs** on the server.
**iTAK (iOS) — Upload server package (recommended):**
Many iTAK builds only offer “Connect with credentials”, “Upload server package”, and “Scan QR”; there is no “Import Trust Store” or “Enroll with Preconfigured Trust”. Use the server package:
1. In **KestrelOS**, open **Settings****TAK Server (ATAK / iTAK)** and click **Download server package (zip)**.
2. Transfer the zip to your iPhone (e.g. AirDrop, email, or open KestrelOS in Safari on the phone, log in, and download there).
3. In **iTAK**: **Settings****Network****Servers****+** → **Upload server package** → select the zip.
4. When prompted, enter your KestrelOS **username** and **password**. The package includes the server cert so iTAK can complete the TLS handshake.
**iTAK — Plain TCP (no SSL):**
If the server package does not work, run CoT without TLS: stop KestrelOS, rename or remove the `.dev-certs` folder, then restart. The CoT server will listen on 8089 with plain TCP. In **Settings** → TAK Server, the QR will show `tcp`. Scan it or add the server manually with **SSL disabled**. You wont get a cert prompt and the connection should reach the server.
**ATAK (Android):** If your build has **Enroll with Preconfigured Trust** and **Import Trust Store**, you can download the trust store from **https://your-server/api/cot/truststore** (when logged in), import the `.p12`, password `kestrelos`, then connect. Otherwise use the **server package** from Settings the same way as iTAK, or use plain TCP as above.
### 3. Verify
- Other TAK clients (ATAK and iTAK) that are connected to the same KestrelOS instance appear on your iTAK map.
- KestrelOS web map shows all connected TAK devices as amber markers (position only; no video).
---
## OIDC users: set ATAK password first
If you sign in to KestrelOS with **OIDC** (SSO), you do not have a KestrelOS login password. To use “Use Authentication” in ATAK/iTAK:
1. Sign in to the **KestrelOS web app** with OIDC.
2. Open **Account** (sidebar).
3. Under **ATAK / device password**, set and confirm a password, then save.
4. In ATAK or iTAK, use your **KestrelOS username** (identifier) and this **ATAK password**.
If you havent set an ATAK password, the TAK Server will reject your connection.
---
## Server configuration (optional)
| Environment variable | Default | Description |
|----------------------|---------|-------------|
| `PORT` | `3000` | Port for the web app and API. |
| `COT_PORT` | `8089` | Port for the CoT (TAK) server. |
| `COT_TTL_MS` | `90000` | How long (ms) a device stays on the map without updates (~90 s). |
| `COT_REQUIRE_AUTH` | `true` | Require username/password before relaying. |
| `COT_SSL_CERT` | — | Path to TLS cert for CoT (optional; default: `.dev-certs/cert.pem`). |
| `COT_SSL_KEY` | — | Path to TLS key (optional; default: `.dev-certs/key.pem`). |
When `.dev-certs/` exists (e.g. `./scripts/gen-dev-cert.sh`), the CoT server uses TLS on port 8089. For **Docker**, expose ports 3000 and 8089:
```bash
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
```
For **Helm**, expose ports 3000 and 8089 in the service or ingress.
---
## Testing the CoT endpoint
The web app is on **port 3000**, CoT on **port 8089**. From the same device (or any host with network access), you can confirm both work.
**Health (API):** `curl -k https://localhost:3000/health` (or `http://localhost:3000/health` if no TLS).
**CoT on port 8089:** If the server uses TLS (e.g. `.dev-certs/`), connect with TLS to port 8089 and send a CoT auth message:
```bash
echo '<event><detail><auth username="itak" password="yourpassword"/></detail></event>' | \
openssl s_client -connect localhost:8089 -quiet 2>/dev/null
```
If the server runs without TLS (plain TCP), use:
```bash
echo '<event><detail><auth username="itak" password="yourpassword"/></detail></event>' | \
nc localhost 8089
```
Replace `localhost` with your server host if testing remotely. **What to expect:** Server logs show `[cot] client connected`, `[cot] frame parsed as traditional`, `[cot] auth attempt username= itak`, and either `auth result valid= true` or `password match= false`.
---
## Troubleshooting
### "Error authenticating with the server" and no `[cot]` logs on the server
If iTAK shows this message but you see **no** `[cot]` lines in the server log, the connection is almost certainly **not reaching** the CoT process.
**With SSL (TLS) and a self-signed cert:** The server only runs your connection handler **after** the TLS handshake completes. If the client does not trust the servers certificate (e.g. self-signed and not imported), the handshake fails on the client side and never completes, so the server never logs “client connected”. **Fix:** Use **Upload server package** (download from KestrelOS Settings) or import the trust store in iTAK (see [Self-signed certificate](#2-self-signed-certificate-required-for-ssl) above). No server logs until the client trusts the cert and the handshake succeeds.
1. **Confirm CoT is listening**
- When the app starts you must see: `[cot] CoT server listening on 0.0.0.0:8089 (TLS)` or `(plain TCP)`.
- If you dont see that, the CoT server didnt start (e.g. port 8089 in use, or youre viewing logs for a different process).
2. **Port and host**
- In iTAK the **port must be 8089** (not 3000). Host = your server IP (e.g. 192.168.2.214).
3. **Reachability from the device**
- From the same network as your phone, run: `nc -zv 192.168.2.214 8089` (or use a "port check" app). If it doesnt connect, the firewall or network is blocking 8089.
4. **SSL and self-signed cert**
- If the server log says **(TLS)**, iTAK must trust the cert: use **Upload server package** (download the zip from KestrelOS Settings) or run CoT without TLS and add the server with SSL disabled (see [Self-signed certificate](#2-self-signed-certificate-required-for-ssl)).
- If the server says **(plain TCP)**, in iTAK **disable** SSL.
5. **Where to look for logs**
- Same terminal where you ran `npm run dev` or `node .output/server/index.mjs`. If you use Docker/systemd, check that processs stdout/stderr.
Once the connection reaches the server youll see at least: `[cot] client connected (TLS) from <ip>`.
---
- **Connection refused:** Ensure ports 3000 (web/API) and 8089 (CoT) are open on the server and in any firewall.
- **"Error authenticating" but you do see `[cot]` logs:** Username must be the KestrelOS **identifier** (login name) and password must match (local: login password; OIDC: ATAK password set in **Account**).
- **Authentication failed (OIDC user):** Set an **ATAK password** in KestrelOS under **Account**.
- **Devices not on KestrelOS map:** They appear only while sending position updates; they drop off after the TTL (~90 s) with no updates.
- **SSL:** If the server uses TLS (`.dev-certs/` or production cert), connect to port 8089 with SSL enabled in ATAK/iTAK.
---
## Summary
| Step | ATAK / iTAK |
|------|-------------|
| Add server | **QR:** KestrelOS **Settings** → scan TAK Server QR with iTAK/ATAK. Or add manually: **Host** = KestrelOS, **Port** = 8089 (CoT). |
| Self-signed SSL (iTAK) | **Settings** → Download **server package (zip)** → in iTAK: **Upload server package** and select it. Or run CoT without TLS (remove `.dev-certs`, restart) and add server with SSL disabled. |
| Auth | Enable “Use Authentication”; **Username** = KestrelOS identifier; **Password** = login password (local) or ATAK password (OIDC). |
| OIDC users | Set **ATAK password** once in KestrelOS **Account** before connecting. |
| Result | CoT is relayed between all connected TAK clients; positions show on the KestrelOS web map as amber markers. |

106
docs/auth.md Normal file
View File

@@ -0,0 +1,106 @@
# Authentication
KestrelOS supports **local login** (username/email + password) and optional **OIDC** (single sign-on with an identity provider). All users must sign in to use the app.
---
## Local login
### First run: bootstrap admin
On first start, if the database has no users, KestrelOS creates an **admin** account:
- **If you set env vars:**
`BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD`
→ That account is created (identifier = email, role = admin).
- **If you do not set them:**
A default admin is created with identifier **`admin`** and a **random password**.
The password is printed in the terminal—copy it and sign in at **/login**.
After first login, change the password or add users via **Members** (admin only).
### Signing in (local)
1. Open **/login** (or youll be redirected there when visiting a protected page).
2. Enter **Email or username** (identifier) and **Password**.
3. Click **Sign in**. Youre redirected to the map or the page you came from.
Local users can always use this form. Their password is stored hashed in the database.
---
## OIDC config and sign up
OIDC lets users sign in with an external identity provider (IdP), e.g. Keycloak, Auth0, or your organizations IdP. There is **no “sign up” inside KestrelOS** for OIDC: accounts are created when a user signs in for the first time (first-time OIDC login creates a KestrelOS user linked to that IdP identity).
### Enabling OIDC
Set these environment variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `OIDC_ISSUER` | Yes | IdP issuer URL (e.g. `https://auth.example.com/realms/myrel`). |
| `OIDC_CLIENT_ID` | Yes | OIDC client ID. |
| `OIDC_CLIENT_SECRET` | Yes | OIDC client secret. |
| `OIDC_LABEL` | No | Button label (default: "Sign in with OIDC"). |
| `OIDC_REDIRECT_URI` | No | Callback URL (default: `{APP_URL}/api/auth/oidc/callback`). |
| `OIDC_SCOPES` | No | Scopes (default: `openid profile email`). |
The app derives the redirect URI if not set:
- Prefer **`NUXT_APP_URL`** or **`APP_URL`** (e.g. `https://kestrelos.example.com`).
Callback = `{that base}/api/auth/oidc/callback`.
- Otherwise it uses **`HOST`**, **`PORT`**, and **`NODE_ENV`** (e.g. `http://localhost:3000/api/auth/oidc/callback`).
### IdP configuration
In your IdP:
1. Create an **OIDC client** (confidential, or public with PKCE if supported).
2. Set **Redirect URI** to:
`https://<your-kestrelos-host>/api/auth/oidc/callback`
(or the same with `http` for local dev).
3. Copy **Client ID** and **Client Secret** into `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET`.
4. Set **Issuer** in `OIDC_ISSUER` (issuer URL from the IdPs discovery document).
### OIDC sign-in flow
1. User opens **/login** and clicks the OIDC button (e.g. “Sign in with OIDC”).
2. They are redirected to the IdP to sign in (and sign up at the IdP if needed).
3. After success, the IdP redirects back to KestrelOS with an authorization code.
4. KestrelOS exchanges the code for tokens and creates or finds the user (by `oidc_issuer` + `oidc_sub`), then logs them in.
**Sign up:** Done at the IdP. The first time a user completes OIDC login, KestrelOS creates a new user; no separate “sign up” page in KestrelOS.
### OIDC and ATAK/iTAK
OIDC users dont have a KestrelOS password. To use **ATAK or iTAK** with “Use Authentication,” they must set an **ATAK password** once in the web app:
1. Sign in with OIDC.
2. Go to **Account**.
3. Under **ATAK / device password**, set and confirm a password, then save.
They then use their **KestrelOS username** (identifier) and this **ATAK password** in the TAK client. See [ATAK and iTAK](atak-itak.md).
---
## Roles
- **Admin** — Can manage users (add, edit, delete, change roles), edit POIs, and add/edit devices (via API).
- **Leader** — Can edit POIs and add/edit devices (via API). Cannot change user roles.
- **Member** — Can view map, cameras, and POIs; can use Share live. Cannot edit POIs or manage devices.
Only admins can change roles (Members page).
---
## Summary
| Goal | Action |
|------|--------|
| First-time setup | Set `BOOTSTRAP_EMAIL` / `BOOTSTRAP_PASSWORD` or use printed default admin password. |
| Local sign-in | Use **/login** with identifier and password. |
| Enable SSO | Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (and optional `OIDC_LABEL`, `OIDC_REDIRECT_URI`). |
| “Sign up” with OIDC | Users sign up at the IdP; first OIDC login in KestrelOS creates their account. |
| Use ATAK/iTAK as OIDC user | Set **ATAK password** under **Account** in the web app. |

128
docs/installation.md Normal file
View File

@@ -0,0 +1,128 @@
# Installation
You can run KestrelOS from source (npm), as a Docker container, or on Kubernetes with Helm.
## Prerequisites
- **Node.js** 20+ (for npm install)
- **Docker** (optional, for container run)
- **Kubernetes** and **Helm 3** (optional, for Helm install)
---
## npm (from source)
Best for development or a single server.
1. **Clone and install**
```bash
git clone <repository-url> kestrelos
cd kestrelos
npm install
```
2. **Start the app**
```bash
npm run dev
```
Open **http://localhost:3000**. The app will create a SQLite database at `data/kestrelos.db` on first run and bootstrap an admin user if none exist (see [Authentication](auth.md)).
3. **Production build**
```bash
npm run build
npm run preview
```
Or run the built app with Node:
```bash
node .output/server/index.mjs
```
For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. `PORT=3000`).
### Environment (npm)
| Variable | Description |
|----------|-------------|
| `HOST` | Bind address (default from Nuxt; use `0.0.0.0` for all interfaces). |
| `PORT` | Port (default `3000`). |
| `DB_PATH` | Path to SQLite file (default `data/kestrelos.db`). |
See [Authentication](auth.md) for `BOOTSTRAP_EMAIL`, `BOOTSTRAP_PASSWORD`, and OIDC variables. See [Map and cameras](map-and-cameras.md) and [ATAK and iTAK](atak-itak.md) for device and CoT options.
---
## Docker
Best for a single server or CI.
1. **Build the image**
```bash
docker build -t kestrelos:latest .
```
2. **Run the container**
```bash
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
```
Open **http://localhost:3000**.
3. **Persist data and set env**
```bash
docker run -p 3000:3000 -p 8089:8089 \
-v kestrelos-data:/app/data \
-e HOST=0.0.0.0 \
-e BOOTSTRAP_EMAIL=admin@example.com \
-e BOOTSTRAP_PASSWORD=yourpassword \
kestrelos:latest
```
Use a volume so the SQLite DB and any uploaded data survive restarts.
### Docker environment
Same as npm; pass with `-e`. CoT (ATAK/iTAK) uses port 8089; expose both 3000 and 8089:
```bash
docker run -p 3000:3000 -p 8089:8089 -v kestrelos-data:/app/data kestrelos:latest
```
---
## Helm (Kubernetes)
Best for production or multi-replica deployments.
### From a Helm registry (e.g. Gitea)
```bash
helm repo add keligrubb --username YOUR_USER --password YOUR_TOKEN https://git.keligrubb.com/api/packages/keligrubb/helm
helm repo update
helm install kestrelos keligrubb/kestrelos
```
### From source
```bash
helm install kestrelos ./helm/kestrelos
```
### Configuration
- Edit **`helm/kestrelos/values.yaml`** for image, replica count, resources, and Ingress.
- Health endpoints: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/ready` (readiness). The chart configures liveness and readiness probes.
- To expose the app, set **`ingress.enabled: true`** and set `ingress.host` (and TLS if needed).
- For ATAK/iTAK, expose port 8089 (CoT) in addition to 3000 (web/API) on the service or via Ingress.
### Upgrade
```bash
helm upgrade kestrelos keligrubb/kestrelos
# or
helm upgrade kestrelos ./helm/kestrelos
```
---
## Next steps
- [Authentication](auth.md) — First login (local or OIDC).
- [Map and cameras](map-and-cameras.md) — Add devices and use the map.
- [ATAK and iTAK](atak-itak.md) — Connect TAK clients.
- [Share live](live-streaming.md) — Stream from your phone.

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

@@ -0,0 +1,105 @@
# Share live (mobile device as a live camera)
**Share live** lets you stream your phones camera and location to KestrelOS. You appear as a **live session** on the map and in the **Cameras** list; others can click your marker to watch the stream in real time. Uses **WebRTC** (Mediasoup) and requires **HTTPS** when using the app from a phone.
---
## How it works
1. You open the **Share live** page on your mobile browser (sidebar → **Share live**; or **/share-live**).
2. You tap **Start sharing**. The app requests camera and location permission.
3. A **live session** is created on the server and your video/location is sent over WebRTC.
4. Your device appears on the **map** and in **Cameras**. Others click your marker or select you in the list to view the stream.
5. Tap **Stop sharing** to end the stream.
Only **admin** and **leader** roles see the **Share live** item in the sidebar; they are the ones who can start a live share. Any signed-in user can view live sessions on the map and Cameras page.
---
## Requirements
- **HTTPS** when using the app from a phone. Browsers require a secure context for camera and geolocation. Use:
- A server with a real TLS certificate, or
- For local testing: a self-signed cert and your machines LAN IP (see below).
- **Camera and location permission** in the browser when prompted.
- **Network:** Server must be reachable from the phone. For WebRTC, **UDP and TCP ports 4000049999** must be open on the server (or the ports Mediasoup is configured to use).
---
## Using Share live on your phone
### 1. Open the app over HTTPS
- **Production:** Open `https://your-kestrelos.example.com`, sign in, then go to **Share live** (sidebar).
- **Local / LAN:** Use the same HTTPS URL you use for the server (e.g. `https://192.168.1.10:3000`). If you use a self-signed cert, accept the browser warning once (e.g. Advanced → Proceed).
### 2. Start sharing
1. Tap **Share live** in the sidebar (or open `/share-live`).
2. Tap **Start sharing**.
3. Allow **camera** and **location** when the browser asks.
4. Wait for “Live — you appear on the map.” Your marker and stream are now visible to others.
### 3. View yourself on the map
- On another device (or in another tab), open the KestrelOS **map**. Your device appears as a live-session marker; click it to open the stream panel.
- Or open **Cameras** and select your session from the list (shown as “Live”).
### 4. Stop sharing
Tap **Stop sharing** on the Share live page. The session ends and your marker disappears after a short time.
---
## Local development: HTTPS and LAN
To test Share live from a phone on your LAN without a public domain or cert:
1. **Generate a self-signed cert** (once) using your machines LAN IP:
```bash
chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123
```
Use your machines actual LAN IP instead of `192.168.1.123`.
2. **Start the dev server** (it will use HTTPS if `.dev-certs/` exists):
```bash
npm run dev
```
3. **On your phone**, open **https://192.168.1.123:3000** (same IP as above). Accept the “untrusted certificate” warning once, then sign in and go to **Share live**.
If you see a warning about `NODE_TLS_REJECT_UNAUTHORIZED=0`, you can ignore it in local dev; the server still works.
---
## WebRTC and firewall
- The server uses **Mediasoup** and needs **UDP and TCP in the range 4000049999** (by default) open for WebRTC.
- The server tries to detect the LAN IP for WebRTC. If you run in **Docker** or have multiple NICs, set **`MEDIASOUP_ANNOUNCED_IP`** to the IP or hostname that clients use to reach the server.
- **Wrong host:** If the Share live page shows “Wrong host” (server hostname vs. client hostname), open the app using the same hostname the server reports, or set `MEDIASOUP_ANNOUNCED_IP` so the server advertises the correct address.
---
## Troubleshooting
| Issue | What to do |
|-------|------------|
| “HTTPS required” or camera/location not available | Open the app over **https://** (not http). On a phone, use a tunnel (e.g. ngrok) or a server with TLS. |
| “Media devices not available” | Ensure youre on HTTPS and that the browser has permission for camera (and location if needed). |
| “WebRTC: failed” / “Wrong host” | Use the same URL on phone and server, or set `MEDIASOUP_ANNOUNCED_IP`. Open firewall for ports 4000049999. |
| Stream doesnt appear for others | Check that the server is reachable and that no firewall blocks WebRTC ports. |
---
## Summary
| Step | Action |
|------|--------|
| 1 | Open KestrelOS over **HTTPS** on your phone and sign in. |
| 2 | Go to **Share live** (sidebar). |
| 3 | Tap **Start sharing** and allow camera and location. |
| 4 | Your device appears on the **map** and **Cameras**; others click your marker or entry to view. |
| 5 | Tap **Stop sharing** when done. |
For local testing, use the dev cert script with your LAN IP and open `https://<LAN-IP>:3000` on the phone. For production, use a proper TLS setup and ensure WebRTC ports (4000049999) are open on the server.

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

@@ -0,0 +1,128 @@
# Map and cameras
KestrelOS shows a **map** with **devices** (cameras, ALPR, NVR, etc.), **POIs**, **live sessions** (Share live), and **ATAK/iTAK** positions. You view streams by clicking markers or using the **Cameras** page.
---
## Using the map
- **Home:** The main view is the map (**/**). It uses OpenStreetMap-style tiles and supports offline tile caching (Leaflet.offline).
- **Layers:**
- **Devices** — Cameras and feeds youve added (API). Click a device marker to open the live stream in a side panel.
- **POIs** — Points of interest (admin/leader can add/edit from **POI** page).
- **Live sessions** — Mobile devices streaming via **Share live**. Click to view the live stream.
- **CoT (ATAK/iTAK)** — Amber markers for connected ATAK/iTAK devices (position only; no stream).
- **Actions:** Pan and zoom as usual. If you have permission, you can add/edit POIs from the **POI** page; device editing is via API (see below).
---
## What counts as a “camera”
In KestrelOS, a **camera** is either:
1. A **device** — A fixed feed (IPTV, ALPR, CCTV, NVR, doorbell, traffic cam, etc.) with a **stream URL** and optional config.
2. A **live session** — A mobile device streaming via **Share live** (WebRTC). These appear automatically while sharing.
The **Cameras** page (sidebar → **Cameras**) lists both: select an entry to view its stream in the panel. The map shows the same sources as markers; clicking a marker also opens the stream.
---
## Device types and stream types
When you add a **device** (via API), you set:
- **device_type** — One of: `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone`. Used for labeling and filtering (e.g. “ALPR”, “NVR”).
- **source_type** — How the stream is delivered:
- **`mjpeg`** — MJPEG over HTTP (single image or MJPEG stream URL).
- **`hls`** — HLS (HTTP Live Streaming); use an `https://` or `http://` URL to an `.m3u8` playlist.
Stream URLs must be **http://** or **https://**; other protocols are rejected.
Examples by use case:
| Use case | device_type | source_type | stream_url example |
|----------|-------------|-------------|---------------------|
| IP camera / CCTV | `ip` or `feed` | `mjpeg` or `hls` | `http://192.168.1.10/mjpg/video.mjpg` |
| ALPR camera | `alpr` | `mjpeg` or `hls` | `https://alpr.example.com/stream.m3u8` |
| NVR feed | `nvr` | `mjpeg` or `hls` | `http://nvr.local/cam1/video.mjpeg` |
| Traffic camera | `traffic` | `mjpeg` or `hls` | `https://traffic.gov/cam123.m3u8` |
| Doorbell | `doorbell` | `mjpeg` or `hls` | As provided by vendor. |
| IPTV channel | `feed` | `hls` | `https://iptv.example.com/channel.m3u8` |
| Drone feed | `drone` | `mjpeg` or `hls` | As provided. |
---
## Adding and editing devices (API)
Only **admin** or **leader** can create or update devices. There is no “Add camera” form in the UI yet; use the HTTP API (e.g. from a script or tool).
### Create a device
**POST** `/api/devices`
Requires auth (session cookie). Body (JSON):
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Display name. |
| `device_type` | string | No | One of: `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone`. Default `feed`. |
| `lat` | number | Yes | Latitude. |
| `lng` | number | Yes | Longitude. |
| `stream_url` | string | No | HTTP(S) URL for MJPEG or HLS. |
| `source_type` | string | No | `mjpeg` or `hls`. Default `mjpeg`. |
| `vendor` | string | No | Optional vendor/label. |
| `config` | string or object | No | Optional JSON config (stored as string). |
Example (curl, after logging in and saving cookie):
```bash
curl -X POST https://your-kestrelos.example.com/api/devices \
-H "Content-Type: application/json" \
-b "kestrelos.session=YOUR_SESSION_COOKIE" \
-d '{
"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 devices
**GET** `/api/devices`
Returns all devices (auth required). The map and Cameras page use **GET** `/api/cameras`, which returns devices plus live sessions and CoT entities.
### Update a device
**PATCH** `/api/devices/:id`
Auth required. Send only the fields you want to change (e.g. `name`, `stream_url`, `lat`, `lng`, `device_type`, `source_type`).
### Delete a device
**DELETE** `/api/devices/:id`
Auth required.
---
## Viewing cameras
- **Map:** Click a device or live-session marker to open the stream in the side panel. Close the panel to return to the map.
- **Cameras page:** Open **Cameras** from the sidebar. The list shows all devices and live sessions; select one to view its stream in the right-hand panel. “Live” badge indicates an active Share live stream.
---
## POIs (points of interest)
Admins and leaders can add and edit POIs from the **POI** page (sidebar → **POI**). POIs appear on the map as pins. They are for reference only (no stream). Use **Map** for viewing and **POI** for managing the list and coordinates.
---
## Summary
| Task | How |
|------|-----|
| View map and streams | Open **/** and click markers, or use **Cameras** page. |
| Add IPTV/ALPR/CCTV/NVR/etc. | **POST** `/api/devices` with `name`, `lat`, `lng`, `stream_url`, `source_type` (`mjpeg` or `hls`), and optional `device_type`, `vendor`. |
| Edit or delete a device | **PATCH** or **DELETE** `/api/devices/:id` (admin/leader). |
| Add your phone as a live feed | Use **Share live** — see [Share live](live-streaming.md). |

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

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

297
package-lock.json generated
View File

@@ -1,21 +1,26 @@
{
"name": "kestrelos",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kestrelos",
"version": "0.3.0",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
"qrcode": "^1.5.4",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",
@@ -6680,6 +6685,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -7422,6 +7436,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -7581,6 +7604,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -8753,6 +8782,24 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/fast-xml-parser": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
"integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -9711,6 +9758,12 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -10397,6 +10450,48 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -10703,6 +10798,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -12506,6 +12610,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -12518,6 +12631,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -12567,7 +12686,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12729,6 +12847,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
@@ -13491,6 +13618,141 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -13789,6 +14051,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reserved-identifiers": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
@@ -14274,8 +14542,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
@@ -14799,6 +15072,18 @@
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/structured-clone-es": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz",
@@ -16670,6 +16955,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test test/e2e",
"test:e2e:ui": "playwright test --ui test/e2e",
@@ -20,13 +21,16 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
"qrcode": "^1.5.4",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",

View File

@@ -31,6 +31,7 @@ openssl req -x509 -newkey rsa:2048 -keyout "$KEY" -out "$CERT" -days 365 -nodes
echo "Created $KEY and $CERT"
echo ""
echo "Next: run npm run dev"
echo " (dev HTTPS and CoT TAK server TLS on port 8089 will use these certs)"
if [ "$IP" != "127.0.0.1" ] && [ "$IP" != "localhost" ]; then
echo "On your phone: open https://${IP}:3000 (accept the security warning once)"
fi

View File

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

View File

@@ -74,6 +74,9 @@ export default defineEventHandler(async (event) => {
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
}
// Invalidate all existing sessions for this user to prevent session fixation
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,253 @@
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'
let tcpServer = null
let 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)
}
}
}
}
async function processFrame(socket, rawMessage, payload, authenticated) {
const requireAuth = socket._cotRequireAuth !== false
const debug = socket._cotDebug === true
const parsed = parseCotPayload(payload)
if (debug) {
let preview = payload.length
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
preview = s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
}
else preview = 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
}
catch { preview = 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex') }
console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
}
if (!parsed) return
if (parsed.type === 'auth') {
if (authenticated) return
console.log('[cot] auth attempt username=', parsed.username)
// Use lock per socket to prevent concurrent auth attempts
const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
await acquire(socketKey, async () => {
// Re-check authentication state after acquiring lock
if (socket._cotAuthenticated || socket.destroyed) return
try {
const valid = await validateCotAuth(parsed.username, parsed.password)
console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
if (!socket.writable || socket.destroyed) return
if (valid) {
clearAuthTimeout(socket)
relaySet.add(socket)
socket._cotAuthenticated = true
}
else {
socket.destroy()
}
}
catch (err) {
console.log('[cot] auth validation error:', err?.message)
if (!socket.destroyed) socket.destroy()
}
}).catch((err) => {
console.log('[cot] auth lock error:', err?.message)
if (!socket.destroyed) socket.destroy()
})
return
}
if (parsed.type === 'cot') {
if (requireAuth && !authenticated) {
socket.destroy()
return
}
updateFromCot(parsed).catch((err) => {
console.error('[cot] Error updating from CoT:', err?.message)
})
if (authenticated) broadcast(socket, rawMessage)
}
}
async function onData(socket, data) {
let buf = socketBuffers.get(socket)
if (!buf) buf = Buffer.alloc(0)
buf = Buffer.concat([buf, 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)
}
while (buf.length > 0) {
let result = parseTakStreamFrame(buf)
let frameType = 'tak'
if (!result && buf[0] === 0x3C) {
result = parseTraditionalXmlFrame(buf)
frameType = 'traditional'
}
if (result && socket._cotDebug) console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
if (!result) break
const { payload, bytesConsumed } = result
const rawMessage = buf.subarray(0, bytesConsumed)
await processFrame(socket, rawMessage, payload, authenticated)
if (socket.destroyed) return
buf = buf.subarray(bytesConsumed)
socketBuffers.set(socket, buf)
}
}
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,
}
tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
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 {
tcpServer = createTcpServer(socket => setupSocket(socket, false))
tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
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)
registerCleanup(async () => {
for (const s of allSockets) {
try {
s.destroy()
}
catch {
/* ignore */
}
}
allSockets.clear()
relaySet.clear()
if (tcpServer) {
tcpServer.close()
tcpServer = null
}
if (tlsServer) {
tlsServer.close()
tlsServer = null
}
})
nitroApp.hooks.hook('close', async () => {
for (const s of allSockets) {
try {
s.destroy()
}
catch {
/* ignore */
}
}
allSockets.clear()
relaySet.clear()
if (tcpServer) {
tcpServer.close()
tcpServer = null
}
if (tlsServer) {
tlsServer.close()
tlsServer = null
}
})
})

View File

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

View File

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

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

@@ -0,0 +1,39 @@
/**
* Async lock utility - Promise-based mutex per key.
* Ensures only one async operation executes per key at a time.
*/
const locks = new Map()
/**
* 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)
let queue = locks.get(lockKey)
if (!queue) {
queue = Promise.resolve()
locks.set(lockKey, queue)
}
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()
}

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

@@ -0,0 +1,23 @@
/**
* Application constants with environment variable support.
*/
// Timeouts (milliseconds)
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
// Ports
export const COT_PORT = Number(process.env.COT_PORT) || 8089
export const WEBSOCKET_PATH = process.env.WEBSOCKET_PATH || '/ws'
// Limits
export const MAX_PAYLOAD_BYTES = Number(process.env.MAX_PAYLOAD_BYTES) || 64 * 1024
export const MAX_STRING_LENGTH = Number(process.env.MAX_STRING_LENGTH) || 1000
export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) || 255
// Mediasoup
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999

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

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

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

@@ -0,0 +1,142 @@
import { XMLParser } from 'fast-xml-parser'
import { MAX_PAYLOAD_BYTES } from './constants.js'
const TAK_MAGIC = 0xBF
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
/**
* @param {Buffer} buf
* @param {number} offset
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
*/
function readVarint(buf, offset) {
let value = 0
let shift = 0
let bytesRead = 0
while (offset + bytesRead < buf.length) {
const b = buf[offset + bytesRead]
bytesRead += 1
value += (b & 0x7F) << shift
if ((b & 0x80) === 0) return { value, bytesRead }
shift += 7
if (shift > 28) return { value: 0, bytesRead: 0 }
}
return { value, bytesRead }
}
/**
* 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] !== TAK_MAGIC) 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] !== 0x3C) 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')
let lat = Number.NaN
let lng = Number.NaN
if (point && typeof point === 'object') {
lat = Number(point['@_lat'] ?? point.lat)
lng = Number(point['@_lon'] ?? point.lon ?? point['@_lng'] ?? point.lng)
}
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
}
}

12
server/utils/cotRouter.js Normal file
View File

@@ -0,0 +1,12 @@
/**
* CoT stream first-byte detection: TAK Protocol (0xBF) or traditional XML (0x3C '<').
* Used by tests and any code that must distinguish CoT from other protocols.
*/
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
}

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

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

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

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

View File

@@ -3,11 +3,13 @@ import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { promisify } from 'node:util'
import { bootstrapAdmin } from './bootstrap.js'
import { registerCleanup } from './shutdown.js'
const require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3')
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const sqlite3 = requireFromRoot('sqlite3')
const SCHEMA_VERSION = 3
const SCHEMA_VERSION = 4
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -111,6 +113,12 @@ const migrateToV3 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
const migrateToV4 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'cot_password_hash')) return
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -122,6 +130,10 @@ const runMigrations = async (run, all, get) => {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
if (version < 4) {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
}
const initDb = async (db, run, all, get) => {
@@ -167,9 +179,91 @@ export async function getDb() {
}
dbInstance = { db, run, all, get }
registerCleanup(async () => {
if (dbInstance) {
try {
await new Promise((resolve, reject) => {
dbInstance.db.close((err) => {
if (err) reject(err)
else resolve()
})
})
}
catch (error) {
console.error('[db] Error closing database during shutdown:', error?.message)
}
dbInstance = null
}
})
return dbInstance
}
/**
* Health check for database connection.
* @returns {Promise<{ healthy: boolean, error?: string }>} Health status
*/
export async function healthCheck() {
try {
const db = await getDb()
await db.get('SELECT 1')
return { healthy: true }
}
catch (error) {
return {
healthy: false,
error: error?.message || String(error),
}
}
}
/**
* Database connection model documentation:
*
* KestrelOS uses SQLite with WAL (Write-Ahead Logging) mode for concurrent access.
* - Single connection instance shared across all requests (singleton pattern)
* - WAL mode allows multiple readers and one writer concurrently
* - Connection is initialized on first getDb() call and reused thereafter
* - Busy timeout is set to 5000ms to handle concurrent access gracefully
* - Transactions are supported via withTransaction() helper
*
* Concurrency considerations:
* - SQLite with WAL handles concurrent reads efficiently
* - Writes are serialized (one at a time)
* - For high write loads, consider migrating to PostgreSQL
* - Current model is suitable for moderate traffic (< 100 req/sec)
*
* Connection lifecycle:
* - Created on first getDb() call
* - Persists for application lifetime
* - Closed during graceful shutdown
* - Test path can be set via setDbPathForTest() for testing
*/
/**
* Execute a callback within a database transaction.
* Automatically commits on success or rolls back on error.
* @param {object} db - Database instance from getDb()
* @param {Function} callback - Async function receiving { run, all, get }
* @returns {Promise<any>} Result of callback
*/
export async function withTransaction(db, callback) {
const { run } = db
await run('BEGIN TRANSACTION')
try {
const result = await callback(db)
await run('COMMIT')
return result
}
catch (error) {
await run('ROLLBACK').catch(() => {
// Ignore rollback errors
})
throw error
}
}
export function closeDb() {
if (!dbInstance) return
try {

86
server/utils/errors.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* Custom error classes and error handling utilities.
*/
/**
* Base application error.
*/
export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message)
this.name = this.constructor.name
this.statusCode = statusCode
this.code = code
Error.captureStackTrace(this, this.constructor)
}
}
/**
* Validation error (400).
*/
export class ValidationError extends AppError {
constructor(message, details = null) {
super(message, 400, 'VALIDATION_ERROR')
this.details = details
}
}
/**
* Not found error (404).
*/
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND')
}
}
/**
* Unauthorized error (401).
*/
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED')
}
}
/**
* Forbidden error (403).
*/
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN')
}
}
/**
* Conflict error (409).
*/
export class ConflictError extends AppError {
constructor(message = 'Conflict') {
super(message, 409, 'CONFLICT')
}
}
/**
* Format error response for API.
* @param {Error} error - Error object
* @returns {object} Formatted error response
*/
export function formatErrorResponse(error) {
if (error instanceof AppError) {
return {
error: {
code: error.code,
message: error.message,
...(error.details && { details: error.details }),
},
}
}
return {
error: {
code: 'INTERNAL_ERROR',
message: error?.message || 'Internal server error',
},
}
}

View File

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

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

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

View File

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

100
server/utils/queries.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* Reusable query functions - eliminates SQL duplication across routes.
*/
const updateEntity = async (db, table, id, updates, getById) => {
if (Object.keys(updates).length === 0) return getById(db, id)
const { buildUpdateQuery } = await import('./queryBuilder.js')
const { query, params } = buildUpdateQuery(table, null, updates)
if (!query) return getById(db, id)
await db.run(query, [...params, id])
return getById(db, id)
}
export async function getDeviceById(db, id) {
const result = await db.get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
return result || null
}
export async function getAllDevices(db) {
return db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
}
export async function createDevice(db, data) {
const id = crypto.randomUUID()
await db.run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, data.name, data.device_type, data.vendor, data.lat, data.lng, data.stream_url, data.source_type, data.config],
)
return getDeviceById(db, id)
}
export async function updateDevice(db, id, updates) {
return updateEntity(db, 'devices', id, updates, getDeviceById)
}
export async function getUserById(db, id) {
const result = await db.get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
return result || null
}
export async function getUserByIdentifier(db, identifier) {
const result = await db.get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
return result || null
}
export async function createUser(db, data) {
const id = crypto.randomUUID()
await db.run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, data.identifier, data.password_hash, data.role, data.created_at, data.auth_provider || 'local', data.oidc_issuer || null, data.oidc_sub || null],
)
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
}
export async function updateUser(db, id, updates) {
if (Object.keys(updates).length === 0) return getUserById(db, id)
const { buildUpdateQuery } = await import('./queryBuilder.js')
const { query, params } = buildUpdateQuery('users', null, updates)
if (!query) return getUserById(db, id)
await db.run(query, [...params, id])
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
}
export async function getPoiById(db, id) {
const result = await db.get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
return result || null
}
export async function getAllPois(db) {
return db.all('SELECT id, lat, lng, label, icon_type FROM pois ORDER BY id')
}
export async function createPoi(db, data) {
const id = crypto.randomUUID()
await db.run(
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
[id, data.lat, data.lng, data.label || '', data.icon_type || 'pin'],
)
return getPoiById(db, id)
}
export async function updatePoi(db, id, updates) {
return updateEntity(db, 'pois', id, updates, getPoiById)
}
export async function getSessionById(db, id) {
const result = await db.get('SELECT id, user_id, expires_at FROM sessions WHERE id = ?', [id])
return result || null
}
export async function createDbSession(db, data) {
await db.run(
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
[data.id, data.user_id, data.created_at, data.expires_at],
)
}
export async function deleteSession(db, id) {
await db.run('DELETE FROM sessions WHERE id = ?', [id])
}

View File

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

24
server/utils/sanitize.js Normal file
View File

@@ -0,0 +1,24 @@
/**
* Input sanitization utilities - pure functions for cleaning user input.
*/
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
const IDENTIFIER_REGEX = /^\w+$/
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
if (typeof str !== 'string') return ''
const trimmed = str.trim()
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
}
export function sanitizeIdentifier(str) {
if (typeof str !== 'string') return ''
const trimmed = str.trim()
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
}
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
return sanitizeString(str, maxLength)
}

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,4 +16,22 @@ describe('members page', () => {
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
it('shows members list and Add user when user is admin', async () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'admin', role: 'admin', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.text()).toContain('Add user')
expect(wrapper.text()).toMatch(/Only admins can change roles/)
})
it('shows members content when user has canEditPois (leader)', async () => {
registerEndpoint('/api/me', () => ({ id: '2', identifier: 'leader', role: 'leader', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Members')
expect(wrapper.text()).toContain('Identifier')
})
})

View File

@@ -7,6 +7,7 @@ describe('useCameras', () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
@@ -15,6 +16,22 @@ describe('useCameras', () => {
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
registerEndpoint('/api/cameras', () => ({
devices: [],
liveSessions: [],
cotEntities,
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.exists()).toBe(true)
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
throw new Error('network')

View File

@@ -37,4 +37,24 @@ describe('useLiveSessions', () => {
expect(el.exists()).toBe(true)
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
})
it('startPolling and stopPolling manage interval', async () => {
registerEndpoint('/api/live', () => [])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { startPolling, stopPolling } = useLiveSessions()
return () => h('div', {
onClick: () => {
startPolling()
startPolling()
stopPolling()
},
})
},
})
const wrapper = await mountSuspended(TestComponent)
await wrapper.trigger('click')
expect(wrapper.exists()).toBe(true)
})
})

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

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

51
test/unit/bootstrap.spec.js vendored Normal file
View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { bootstrapAdmin } from '../../server/utils/bootstrap.js'
describe('bootstrapAdmin', () => {
let run
let get
beforeEach(() => {
run = vi.fn().mockResolvedValue(undefined)
get = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
delete process.env.BOOTSTRAP_EMAIL
delete process.env.BOOTSTRAP_PASSWORD
})
it('returns without inserting when users exist', async () => {
get.mockResolvedValue({ n: 1 })
await bootstrapAdmin(run, get)
expect(get).toHaveBeenCalledWith('SELECT COUNT(*) as n FROM users')
expect(run).not.toHaveBeenCalled()
})
it('inserts default admin when no users and no env', async () => {
get.mockResolvedValue({ n: 0 })
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await bootstrapAdmin(run, get)
expect(run).toHaveBeenCalledTimes(1)
const args = run.mock.calls[0][1]
expect(args[1]).toBe('admin') // identifier
expect(args[3]).toBe('admin') // role
expect(logSpy).toHaveBeenCalled()
logSpy.mockRestore()
})
it('inserts admin with BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD when set', async () => {
get.mockResolvedValue({ n: 0 })
process.env.BOOTSTRAP_EMAIL = ' admin@example.com '
process.env.BOOTSTRAP_PASSWORD = 'secret123'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await bootstrapAdmin(run, get)
expect(run).toHaveBeenCalledTimes(1)
const args = run.mock.calls[0][1]
expect(args[1]).toBe('admin@example.com') // identifier
expect(args[3]).toBe('admin') // role
expect(logSpy).not.toHaveBeenCalled()
logSpy.mockRestore()
})
})

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_ENTITY_TTL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
WEBSOCKET_PATH,
MAX_PAYLOAD_BYTES,
MAX_STRING_LENGTH,
MAX_IDENTIFIER_LENGTH,
MEDIASOUP_RTC_MIN_PORT,
MEDIASOUP_RTC_MAX_PORT,
} from '../../server/utils/constants.js'
describe('constants', () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
expect(WEBSOCKET_PATH).toBe('/ws')
expect(MAX_PAYLOAD_BYTES).toBe(64 * 1024)
expect(MAX_STRING_LENGTH).toBe(1000)
expect(MAX_IDENTIFIER_LENGTH).toBe(255)
expect(MEDIASOUP_RTC_MIN_PORT).toBe(40000)
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
})
it('uses env var values when set', () => {
process.env.COT_AUTH_TIMEOUT_MS = '20000'
process.env.LIVE_SESSION_TTL_MS = '120000'
process.env.COT_PORT = '9090'
process.env.MAX_STRING_LENGTH = '2000'
// Re-import to get new values
const {
COT_AUTH_TIMEOUT_MS: timeout,
LIVE_SESSION_TTL_MS: ttl,
COT_PORT: port,
MAX_STRING_LENGTH: maxLen,
} = require('../../server/utils/constants.js')
// Note: In actual usage, constants are evaluated at module load time
// This test verifies the pattern works
expect(typeof timeout).toBe('number')
expect(typeof ttl).toBe('number')
expect(typeof port).toBe('number')
expect(typeof maxLen).toBe('number')
})
it('handles invalid env var values gracefully', () => {
// Constants are evaluated at module load time, so env vars set in tests won't affect them
// This test verifies the pattern: Number(process.env.VAR) || default
const invalidValue = Number('invalid')
expect(Number.isNaN(invalidValue)).toBe(true)
const fallback = invalidValue || 15000
expect(fallback).toBe(15000)
})
})

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

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

155
test/unit/cotParser.spec.js Normal file
View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest'
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../../../server/utils/cotParser.js'
function buildTakFrame(payload) {
const buf = Buffer.from(payload, 'utf8')
let n = buf.length
const varint = []
while (true) {
const byte = n & 0x7F
n >>>= 7
if (n === 0) {
varint.push(byte)
break
}
varint.push(byte | 0x80)
}
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
}
describe('cotParser', () => {
describe('parseTakStreamFrame', () => {
it('parses valid frame', () => {
const payload = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const frame = buildTakFrame(payload)
const result = parseTakStreamFrame(frame)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(payload)
expect(result.bytesConsumed).toBe(frame.length)
})
it('returns null for incomplete buffer', () => {
const frame = buildTakFrame('<e/>')
const partial = frame.subarray(0, 2)
expect(parseTakStreamFrame(partial)).toBeNull()
})
it('returns null for wrong magic', () => {
const payload = '<e/>'
const buf = Buffer.concat([Buffer.from([0x00]), Buffer.from([payload.length]), Buffer.from(payload)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
it('returns null for payload length exceeding max', () => {
const hugeLen = 64 * 1024 + 1
const varint = []
let n = hugeLen
while (true) {
varint.push(n & 0x7F)
n >>>= 7
if (n === 0) break
varint[varint.length - 1] |= 0x80
}
const buf = Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
})
describe('parseTraditionalXmlFrame', () => {
it('parses one XML message delimited by </event>', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(xml)
expect(result.bytesConsumed).toBe(buf.length)
})
it('returns null when buffer does not start with <', () => {
expect(parseTraditionalXmlFrame(Buffer.from('x<event></event>'))).toBeNull()
expect(parseTraditionalXmlFrame(Buffer.from([0xBF, 0x00]))).toBeNull()
})
it('returns null when </event> not yet received', () => {
const partial = Buffer.from('<event uid="x"><point lat="1" lon="2"/>', 'utf8')
expect(parseTraditionalXmlFrame(partial)).toBeNull()
})
it('extracted payload parses as auth CoT', () => {
const xml = '<event><detail><auth username="itak" password="mypass"/></detail></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
const parsed = parseCotPayload(result.payload)
expect(parsed).not.toBeNull()
expect(parsed.type).toBe('auth')
expect(parsed.username).toBe('itak')
expect(parsed.password).toBe('mypass')
})
})
describe('parseCotPayload', () => {
it('parses position CoT XML', () => {
const xml = '<event uid="device-1" type="a-f-G-U-C"><point lat="37.7" lon="-122.4"/><detail><contact callsign="Bravo"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.id).toBe('device-1')
expect(result.lat).toBe(37.7)
expect(result.lng).toBe(-122.4)
expect(result.label).toBe('Bravo')
})
it('parses auth CoT with detail.auth', () => {
const xml = '<event><detail><auth username="user1" password="secret123"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('user1')
expect(result.password).toBe('secret123')
})
it('parses auth CoT with __auth', () => {
const xml = '<event><detail><__auth username="u2" password="p2"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('u2')
expect(result.password).toBe('p2')
})
it('returns null for auth with empty username', () => {
const xml = '<event><detail><auth username=" " password="p"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).toBeNull()
})
it('parses position with point.lat and point.lon (no @_ prefix)', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="5" lon="10"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(5)
expect(result.lng).toBe(10)
})
it('returns null for non-XML payload', () => {
expect(parseCotPayload(Buffer.from('not xml'))).toBeNull()
})
it('uses uid as label when no contact/callsign', () => {
const xml = '<event uid="device-99" type="a-f-G"><point lat="1" lon="2"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.label).toBe('device-99')
})
it('uses point inside event when not at root', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="10" lon="20"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(10)
expect(result.lng).toBe(20)
})
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { isCotFirstByte, COT_FIRST_BYTE_TAK, COT_FIRST_BYTE_XML } from '../../server/utils/cotRouter.js'
describe('cotRouter', () => {
describe('isCotFirstByte', () => {
it('returns true for TAK Protocol (0xBF)', () => {
expect(isCotFirstByte(0xBF)).toBe(true)
expect(isCotFirstByte(COT_FIRST_BYTE_TAK)).toBe(true)
})
it('returns true for traditional XML (<)', () => {
expect(isCotFirstByte(0x3C)).toBe(true)
expect(isCotFirstByte(COT_FIRST_BYTE_XML)).toBe(true)
})
it('returns false for HTTP-like first bytes', () => {
expect(isCotFirstByte(0x47)).toBe(false) // 'G' GET
expect(isCotFirstByte(0x50)).toBe(false) // 'P' POST
expect(isCotFirstByte(0x48)).toBe(false) // 'H' HEAD
})
it('returns false for other bytes', () => {
expect(isCotFirstByte(0x00)).toBe(false)
expect(isCotFirstByte(0x16)).toBe(false) // TLS client hello
})
})
})

View File

@@ -0,0 +1,47 @@
/**
* Tests that the CoT parse-and-store path behaves as when a fake ATAK client sends TAK stream frames.
* Uses the same framing and payload parsing the server uses; does not start a real TCP server.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { buildTakFrame, buildPositionCotXml } from '../helpers/fakeAtakClient.js'
import { parseTakStreamFrame, parseCotPayload } from '../../server/utils/cotParser.js'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../server/utils/cotStore.js'
describe('cotServer (parse-and-store path)', () => {
beforeEach(() => {
clearCotStore()
})
it('stores entity when receiving TAK stream frame with position CoT XML', async () => {
const xml = buildPositionCotXml({ uid: 'device-1', lat: 37.7, lon: -122.4, callsign: 'Bravo' })
const frame = buildTakFrame(xml)
const parsedFrame = parseTakStreamFrame(frame)
expect(parsedFrame).not.toBeNull()
const parsed = parseCotPayload(parsedFrame.payload)
expect(parsed).not.toBeNull()
expect(parsed.type).toBe('cot')
await updateFromCot(parsed)
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].id).toBe('device-1')
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Bravo')
})
it('updates same uid on multiple messages', async () => {
const xml1 = buildPositionCotXml({ uid: 'u1', lat: 1, lon: 2 })
const xml2 = buildPositionCotXml({ uid: 'u1', lat: 3, lon: 4, callsign: 'Updated' })
const frame1 = buildTakFrame(xml1)
const frame2 = buildTakFrame(xml2)
const p1 = parseCotPayload(parseTakStreamFrame(frame1).payload)
const p2 = parseCotPayload(parseTakStreamFrame(frame2).payload)
await updateFromCot(p1)
await updateFromCot(p2)
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].lat).toBe(3)
expect(active[0].lng).toBe(4)
expect(active[0].label).toBe('Updated')
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
describe('cotStore', () => {
beforeEach(() => {
clearCotStore()
})
it('upserts entity by id', async () => {
await updateFromCot({ id: 'uid-1', lat: 37.7, lng: -122.4, label: 'Alpha' })
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].id).toBe('uid-1')
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Alpha')
})
it('updates same uid', async () => {
await updateFromCot({ id: 'uid-1', lat: 37.7, lng: -122.4 })
await updateFromCot({ id: 'uid-1', lat: 38, lng: -123, label: 'Updated' })
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].lat).toBe(38)
expect(active[0].lng).toBe(-123)
expect(active[0].label).toBe('Updated')
})
it('ignores invalid parsed (no id)', async () => {
await updateFromCot({ lat: 37, lng: -122 })
const active = await getActiveEntities()
expect(active).toHaveLength(0)
})
it('ignores invalid parsed (bad coords)', async () => {
await updateFromCot({ id: 'x', lat: Number.NaN, lng: -122 })
await updateFromCot({ id: 'y', lat: 37, lng: Infinity })
const active = await getActiveEntities()
expect(active).toHaveLength(0)
})
it('prunes expired entities after getActiveEntities', async () => {
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
const active1 = await getActiveEntities(100)
expect(active1).toHaveLength(1)
await new Promise(r => setTimeout(r, 150))
const active2 = await getActiveEntities(100)
expect(active2).toHaveLength(0)
})
it('returns multiple active entities within TTL', async () => {
await updateFromCot({ id: 'a', lat: 1, lng: 2, label: 'A' })
await updateFromCot({ id: 'b', lat: 3, lng: 4, label: 'B' })
const active = await getActiveEntities()
expect(active).toHaveLength(2)
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
})
})

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { getDb, setDbPathForTest, withTransaction, healthCheck } from '../../server/utils/db.js'
describe('db', () => {
beforeEach(() => {
@@ -53,4 +53,71 @@ describe('db', () => {
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ id, name: 'Traffic Cam', device_type: 'traffic', lat: 37.7, lng: -122.4, stream_url: 'https://example.com/stream', source_type: 'mjpeg' })
})
describe('withTransaction', () => {
it('commits on success', async () => {
const db = await getDb()
const id = 'test-transaction-id'
await withTransaction(db, async ({ run, get }) => {
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'transaction@test.com', 'salt:hash', 'member', new Date().toISOString(), 'local', null, null],
)
return await get('SELECT id FROM users WHERE id = ?', [id])
})
const { get } = await getDb()
const user = await get('SELECT id FROM users WHERE id = ?', [id])
expect(user).toBeDefined()
expect(user.id).toBe(id)
})
it('rolls back on error', async () => {
const db = await getDb()
const id = 'test-rollback-id'
try {
await withTransaction(db, async ({ run }) => {
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'rollback@test.com', 'salt:hash', 'member', new Date().toISOString(), 'local', null, null],
)
throw new Error('Test error')
})
}
catch (error) {
expect(error.message).toBe('Test error')
}
const { get } = await getDb()
const user = await get('SELECT id FROM users WHERE id = ?', [id])
expect(user).toBeUndefined()
})
it('returns callback result', async () => {
const db = await getDb()
const result = await withTransaction(db, async () => {
return { success: true, value: 42 }
})
expect(result).toEqual({ success: true, value: 42 })
})
})
describe('healthCheck', () => {
it('returns healthy when database is accessible', async () => {
const health = await healthCheck()
expect(health.healthy).toBe(true)
expect(health.error).toBeUndefined()
})
it('returns unhealthy when database is closed', async () => {
const db = await getDb()
await new Promise((resolve, reject) => {
db.db.close((err) => {
if (err) reject(err)
else resolve()
})
})
setDbPathForTest(':memory:')
const health = await healthCheck()
expect(health.healthy).toBe(true)
})
})
})

View File

@@ -40,6 +40,13 @@ describe('deviceUtils', () => {
expect(rowToDevice({ id: 'd1', name: 'x', device_type: 'feed', lat: Number.NaN, lng: 0, stream_url: '', source_type: 'mjpeg' })).toBe(null)
})
it('coerces string lat/lng to numbers', () => {
const row = { id: 'd1', name: 'x', device_type: 'feed', lat: '37.5', lng: '-122.0', stream_url: '', source_type: 'mjpeg', config: null }
const out = rowToDevice(row)
expect(out?.lat).toBe(37.5)
expect(out?.lng).toBe(-122)
})
it('coerces non-string vendor, stream_url, config to null or empty', () => {
const row = {
id: 'd1',
@@ -92,6 +99,21 @@ describe('deviceUtils', () => {
}
expect(sanitizeDeviceForResponse(device).streamUrl).toBe('')
})
it('sanitizes stream_url to empty when not http(s)', () => {
const device = {
id: 'd1',
name: 'x',
device_type: 'feed',
vendor: null,
lat: 0,
lng: 0,
stream_url: 'ftp://example.com',
source_type: 'mjpeg',
config: null,
}
expect(sanitizeDeviceForResponse(device).streamUrl).toBe('')
})
})
describe('validateDeviceBody', () => {

118
test/unit/errors.spec.js Normal file
View File

@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
ConflictError,
formatErrorResponse,
} from '../../server/utils/errors.js'
describe('errors', () => {
describe('AppError', () => {
it('creates error with default status code', () => {
const error = new AppError('Test error')
expect(error.message).toBe('Test error')
expect(error.statusCode).toBe(500)
expect(error.code).toBe('INTERNAL_ERROR')
expect(error).toBeInstanceOf(Error)
})
it('creates error with custom status code and code', () => {
const error = new AppError('Custom error', 400, 'CUSTOM_CODE')
expect(error.statusCode).toBe(400)
expect(error.code).toBe('CUSTOM_CODE')
})
})
describe('ValidationError', () => {
it('creates validation error with 400 status', () => {
const error = new ValidationError('Invalid input')
expect(error.statusCode).toBe(400)
expect(error.code).toBe('VALIDATION_ERROR')
expect(error.details).toBeNull()
})
it('includes details when provided', () => {
const details = { field: 'email', reason: 'invalid format' }
const error = new ValidationError('Invalid input', details)
expect(error.details).toEqual(details)
})
})
describe('NotFoundError', () => {
it('creates not found error with default message', () => {
const error = new NotFoundError()
expect(error.statusCode).toBe(404)
expect(error.code).toBe('NOT_FOUND')
expect(error.message).toBe('Resource not found')
})
it('creates not found error with custom resource', () => {
const error = new NotFoundError('User')
expect(error.message).toBe('User not found')
})
})
describe('UnauthorizedError', () => {
it('creates unauthorized error', () => {
const error = new UnauthorizedError()
expect(error.statusCode).toBe(401)
expect(error.code).toBe('UNAUTHORIZED')
expect(error.message).toBe('Unauthorized')
})
it('creates unauthorized error with custom message', () => {
const error = new UnauthorizedError('Invalid credentials')
expect(error.message).toBe('Invalid credentials')
})
})
describe('ForbiddenError', () => {
it('creates forbidden error', () => {
const error = new ForbiddenError()
expect(error.statusCode).toBe(403)
expect(error.code).toBe('FORBIDDEN')
})
})
describe('ConflictError', () => {
it('creates conflict error', () => {
const error = new ConflictError()
expect(error.statusCode).toBe(409)
expect(error.code).toBe('CONFLICT')
})
})
describe('formatErrorResponse', () => {
it('formats AppError correctly', () => {
const error = new ValidationError('Invalid input', { field: 'email' })
const response = formatErrorResponse(error)
expect(response).toEqual({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: { field: 'email' },
},
})
})
it('formats generic Error correctly', () => {
const error = new Error('Generic error')
const response = formatErrorResponse(error)
expect(response).toEqual({
error: {
code: 'INTERNAL_ERROR',
message: 'Generic error',
},
})
})
it('handles error without message', () => {
const error = {}
const response = formatErrorResponse(error)
expect(response.error.message).toBe('Internal server error')
})
})
})

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createSession,
getLiveSession,
@@ -6,15 +6,23 @@ import {
deleteLiveSession,
getActiveSessions,
getActiveSessionByUserId,
getOrCreateSession,
clearSessions,
} from '../../../server/utils/liveSessions.js'
vi.mock('../../../server/utils/mediasoup.js', () => ({
getProducer: vi.fn().mockReturnValue(null),
getTransport: vi.fn().mockReturnValue(null),
closeRouter: vi.fn().mockResolvedValue(undefined),
}))
describe('liveSessions', () => {
let sessionId
beforeEach(() => {
beforeEach(async () => {
clearSessions()
sessionId = createSession('test-user', 'Test Session').id
const session = await createSession('test-user', 'Test Session')
sessionId = session.id
})
it('creates a session with WebRTC fields', () => {
@@ -28,15 +36,15 @@ describe('liveSessions', () => {
expect(session.transportId).toBeNull()
})
it('updates location', () => {
updateLiveSession(sessionId, { lat: 37.7, lng: -122.4 })
it('updates location', async () => {
await updateLiveSession(sessionId, { lat: 37.7, lng: -122.4 })
const session = getLiveSession(sessionId)
expect(session.lat).toBe(37.7)
expect(session.lng).toBe(-122.4)
})
it('updates WebRTC fields', () => {
updateLiveSession(sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
it('updates WebRTC fields', async () => {
await updateLiveSession(sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
const session = getLiveSession(sessionId)
expect(session.routerId).toBe('router-1')
expect(session.producerId).toBe('producer-1')
@@ -44,7 +52,7 @@ describe('liveSessions', () => {
})
it('returns hasStream instead of hasSnapshot', async () => {
updateLiveSession(sessionId, { producerId: 'producer-1' })
await updateLiveSession(sessionId, { producerId: 'producer-1' })
const active = await getActiveSessions()
const session = active.find(s => s.id === sessionId)
expect(session).toBeDefined()
@@ -58,27 +66,27 @@ describe('liveSessions', () => {
expect(session.hasStream).toBe(false)
})
it('deletes a session', () => {
deleteLiveSession(sessionId)
it('deletes a session', async () => {
await deleteLiveSession(sessionId)
const session = getLiveSession(sessionId)
expect(session).toBeUndefined()
})
it('getActiveSessionByUserId returns session for same user when active', () => {
const found = getActiveSessionByUserId('test-user')
it('getActiveSessionByUserId returns session for same user when active', async () => {
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeDefined()
expect(found.id).toBe(sessionId)
})
it('getActiveSessionByUserId returns undefined for unknown user', () => {
const found = getActiveSessionByUserId('other-user')
it('getActiveSessionByUserId returns undefined for unknown user', async () => {
const found = await getActiveSessionByUserId('other-user')
expect(found).toBeUndefined()
})
it('getActiveSessionByUserId returns undefined for expired session', () => {
it('getActiveSessionByUserId returns undefined for expired session', async () => {
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const found = getActiveSessionByUserId('test-user')
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeUndefined()
})
@@ -89,4 +97,43 @@ describe('liveSessions', () => {
expect(active.find(s => s.id === sessionId)).toBeUndefined()
expect(getLiveSession(sessionId)).toBeUndefined()
})
it('getActiveSessions runs cleanup for expired session with producer and transport', async () => {
const { getProducer, getTransport, closeRouter } = await import('../../../server/utils/mediasoup.js')
const mockProducer = { close: vi.fn() }
const mockTransport = { close: vi.fn() }
getProducer.mockReturnValue(mockProducer)
getTransport.mockReturnValue(mockTransport)
closeRouter.mockResolvedValue(undefined)
await updateLiveSession(sessionId, { producerId: 'p1', transportId: 't1', routerId: 'r1' })
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const active = await getActiveSessions()
expect(active.find(s => s.id === sessionId)).toBeUndefined()
expect(mockProducer.close).toHaveBeenCalled()
expect(mockTransport.close).toHaveBeenCalled()
expect(closeRouter).toHaveBeenCalledWith(sessionId)
})
it('getOrCreateSession returns existing active session', async () => {
const session = await getOrCreateSession('test-user', 'New Label')
expect(session.id).toBe(sessionId)
expect(session.userId).toBe('test-user')
})
it('getOrCreateSession creates new session when none exists', async () => {
const session = await getOrCreateSession('new-user', 'New Session')
expect(session.userId).toBe('new-user')
expect(session.label).toBe('New Session')
})
it('getOrCreateSession handles concurrent calls atomically', async () => {
const promises = []
for (let i = 0; i < 5; i++) {
promises.push(getOrCreateSession('concurrent-user', 'Concurrent'))
}
const sessions = await Promise.all(promises)
const uniqueIds = new Set(sessions.map(s => s.id))
expect(uniqueIds.size).toBe(1)
})
})

View File

@@ -1,71 +1,119 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { info, error, warn, debug, setContext, clearContext, runWithContext } from '../../server/utils/logger.js'
describe('logger', () => {
let fetchMock
let originalLog
let originalError
let originalWarn
let originalDebug
let logCalls
let errorCalls
let warnCalls
let debugCalls
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('$fetch', fetchMock)
vi.useFakeTimers()
logCalls = []
errorCalls = []
warnCalls = []
debugCalls = []
originalLog = console.log
originalError = console.error
originalWarn = console.warn
originalDebug = console.debug
console.log = vi.fn((...args) => logCalls.push(args))
console.error = vi.fn((...args) => errorCalls.push(args))
console.warn = vi.fn((...args) => warnCalls.push(args))
console.debug = vi.fn((...args) => debugCalls.push(args))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
console.log = originalLog
console.error = originalError
console.warn = originalWarn
console.debug = originalDebug
})
it('initLogger sets context', () => {
initLogger('sess-1', 'user-1')
logError('test', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
method: 'POST',
body: expect.objectContaining({
sessionId: 'sess-1',
userId: 'user-1',
level: 'error',
message: 'test',
}),
}))
it('logs info message', () => {
info('Test message')
expect(logCalls.length).toBe(1)
const logMsg = logCalls[0][0]
expect(logMsg).toContain('[INFO]')
expect(logMsg).toContain('Test message')
})
it('logError sends error level', () => {
logError('err', { code: 1 })
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'error', message: 'err', data: { code: 1 } }),
}))
it('includes request context when set', async () => {
await runWithContext('req-123', 'user-456', async () => {
info('Test message')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
expect(logMsg).toContain('user-456')
})
})
it('logWarn sends warn level', () => {
logWarn('warn', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'warn', message: 'warn' }),
}))
it('includes additional context', () => {
info('Test message', { key: 'value', count: 42 })
const logMsg = logCalls[0][0]
expect(logMsg).toContain('key')
expect(logMsg).toContain('value')
expect(logMsg).toContain('42')
})
it('logInfo sends info level', () => {
logInfo('info', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'info', message: 'info' }),
}))
it('logs error with stack trace', () => {
const err = new Error('Test error')
error('Failed', { error: err })
expect(errorCalls.length).toBe(1)
const errorMsg = errorCalls[0][0]
expect(errorMsg).toContain('[ERROR]')
expect(errorMsg).toContain('Failed')
expect(errorMsg).toContain('stack')
})
it('logDebug sends debug level', () => {
logDebug('debug', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'debug', message: 'debug' }),
}))
it('logs warning', () => {
warn('Warning message')
expect(warnCalls.length).toBe(1)
const warnMsg = warnCalls[0][0]
expect(warnMsg).toContain('[WARN]')
})
it('does not throw when $fetch rejects', async () => {
vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(new Error('network')))
logError('x', {})
vi.advanceTimersByTime(10)
await vi.advanceTimersByTimeAsync(0)
it('logs debug only in development', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
debug('Debug message')
expect(debugCalls.length).toBe(1)
process.env.NODE_ENV = originalEnv
})
it('does not log debug in production', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
debug('Debug message')
expect(debugCalls.length).toBe(0)
process.env.NODE_ENV = originalEnv
})
it('clears context', async () => {
await runWithContext('req-123', 'user-456', async () => {
info('Test with context')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
})
// Context should be cleared after runWithContext completes
info('Test without context')
const logMsg = logCalls[logCalls.length - 1][0]
expect(logMsg).not.toContain('req-123')
})
it('supports deprecated setContext/clearContext API', async () => {
await runWithContext(null, null, async () => {
setContext('req-123', 'user-456')
info('Test message')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
expect(logMsg).toContain('user-456')
clearContext()
info('Test after clear')
const logMsg2 = logCalls[1][0]
expect(logMsg2).not.toContain('req-123')
})
})
})

View File

@@ -6,6 +6,8 @@ import {
getCodeChallenge,
getOidcRedirectUri,
getOidcConfig,
buildAuthorizeUrl,
exchangeCode,
} from '../../server/utils/oidc.js'
describe('oidc', () => {
@@ -121,5 +123,31 @@ describe('oidc', () => {
const config = await getOidcConfig()
expect(config).toBeNull()
})
it('returns null when only some OIDC env vars set', async () => {
process.env.OIDC_ISSUER = 'https://idp.example.com'
process.env.OIDC_CLIENT_ID = 'client'
delete process.env.OIDC_CLIENT_SECRET
const config = await getOidcConfig()
expect(config).toBeNull()
delete process.env.OIDC_ISSUER
delete process.env.OIDC_CLIENT_ID
})
})
describe('buildAuthorizeUrl', () => {
it('is a function that accepts config and params', () => {
expect(typeof buildAuthorizeUrl).toBe('function')
expect(buildAuthorizeUrl.length).toBe(2)
})
})
describe('exchangeCode', () => {
it('rejects when grant fails', async () => {
const config = {}
const currentUrl = 'https://app/api/auth/oidc/callback?code=abc&state=s'
const checks = { state: 's', nonce: 'n', codeVerifier: 'v' }
await expect(exchangeCode(config, currentUrl, checks)).rejects.toBeDefined()
})
})
})

View File

@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest'
import { POI_ICON_TYPES } from '../../server/utils/poiConstants.js'
describe('poiConstants', () => {
it('exports POI_ICON_TYPES as frozen array', () => {
expect(POI_ICON_TYPES).toEqual(['pin', 'flag', 'waypoint'])
expect(Object.isFrozen(POI_ICON_TYPES)).toBe(true)
})
})

347
test/unit/queries.spec.js Normal file
View File

@@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import {
getDeviceById,
getAllDevices,
createDevice,
updateDevice,
getUserById,
getUserByIdentifier,
createUser,
updateUser,
getPoiById,
getAllPois,
createPoi,
updatePoi,
getSessionById,
createDbSession,
deleteSession,
} from '../../server/utils/queries.js'
describe('queries', () => {
let db
beforeEach(async () => {
setDbPathForTest(':memory:')
db = await getDb()
})
afterEach(() => {
setDbPathForTest(null)
})
describe('device queries', () => {
it('getDeviceById returns null for non-existent device', async () => {
const device = await getDeviceById(db, 'non-existent')
expect(device).toBeNull()
})
it('createDevice and getDeviceById work together', async () => {
const deviceData = {
name: 'Test Device',
device_type: 'traffic',
vendor: 'Test Vendor',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'mjpeg',
config: null,
}
const created = await createDevice(db, deviceData)
expect(created).toBeDefined()
expect(created.name).toBe('Test Device')
const retrieved = await getDeviceById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.name).toBe('Test Device')
})
it('createDevice handles vendor null', async () => {
const deviceData = {
name: 'Test',
device_type: 'feed',
vendor: null,
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
}
const created = await createDevice(db, deviceData)
expect(created.vendor).toBeNull()
})
it('createDevice handles all optional fields', async () => {
const deviceData = {
name: 'Full Device',
device_type: 'traffic',
vendor: 'Vendor Name',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'hls',
config: '{"key":"value"}',
}
const created = await createDevice(db, deviceData)
expect(created.name).toBe('Full Device')
expect(created.vendor).toBe('Vendor Name')
expect(created.stream_url).toBe('https://example.com/stream')
expect(created.source_type).toBe('hls')
expect(created.config).toBe('{"key":"value"}')
})
it('getAllDevices returns all devices', async () => {
await createDevice(db, {
name: 'Device 1',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
await createDevice(db, {
name: 'Device 2',
device_type: 'traffic',
lat: 41.7128,
lng: -75.0060,
stream_url: '',
source_type: 'hls',
config: null,
})
const devices = await getAllDevices(db)
expect(devices).toHaveLength(2)
})
it('updateDevice updates device fields', async () => {
const created = await createDevice(db, {
name: 'Original',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
const updated = await updateDevice(db, created.id, {
name: 'Updated',
lat: 41.7128,
})
expect(updated.name).toBe('Updated')
expect(updated.lat).toBe(41.7128)
})
it('updateDevice returns existing device when no updates', async () => {
const created = await createDevice(db, {
name: 'Test',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
const result = await updateDevice(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('user queries', () => {
it('getUserById returns null for non-existent user', async () => {
const user = await getUserById(db, 'non-existent')
expect(user).toBeNull()
})
it('createUser and getUserById work together', async () => {
const userData = {
identifier: 'testuser',
password_hash: 'hash123',
role: 'admin',
created_at: new Date().toISOString(),
auth_provider: 'local',
}
const created = await createUser(db, userData)
expect(created).toBeDefined()
expect(created.identifier).toBe('testuser')
const retrieved = await getUserById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.identifier).toBe('testuser')
})
it('createUser defaults auth_provider to local', async () => {
const userData = {
identifier: 'testuser2',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
}
const created = await createUser(db, userData)
expect(created.auth_provider).toBe('local')
})
it('createUser handles oidc fields', async () => {
const userData = {
identifier: 'oidcuser',
password_hash: null,
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'oidc',
oidc_issuer: 'https://example.com',
oidc_sub: 'sub123',
}
const created = await createUser(db, userData)
expect(created.auth_provider).toBe('oidc')
})
it('getUserByIdentifier finds user by identifier', async () => {
await createUser(db, {
identifier: 'findme',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const user = await getUserByIdentifier(db, 'findme')
expect(user).toBeDefined()
expect(user.identifier).toBe('findme')
})
it('updateUser updates user fields', async () => {
const created = await createUser(db, {
identifier: 'original',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const updated = await updateUser(db, created.id, {
role: 'admin',
})
expect(updated.role).toBe('admin')
})
it('updateUser returns existing user when no updates', async () => {
const created = await createUser(db, {
identifier: 'test',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const result = await updateUser(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('POI queries', () => {
it('getPoiById returns null for non-existent POI', async () => {
const poi = await getPoiById(db, 'non-existent')
expect(poi).toBeNull()
})
it('createPoi and getPoiById work together', async () => {
const poiData = {
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
icon_type: 'flag',
}
const created = await createPoi(db, poiData)
expect(created).toBeDefined()
expect(created.label).toBe('Test POI')
const retrieved = await getPoiById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.label).toBe('Test POI')
})
it('createPoi defaults label and icon_type', async () => {
const poiData = {
lat: 40.7128,
lng: -74.0060,
}
const created = await createPoi(db, poiData)
expect(created.label).toBe('')
expect(created.icon_type).toBe('pin')
})
it('getAllPois returns all POIs', async () => {
await createPoi(db, { lat: 40.7128, lng: -74.0060, label: 'POI 1' })
await createPoi(db, { lat: 41.7128, lng: -75.0060, label: 'POI 2' })
const pois = await getAllPois(db)
expect(pois).toHaveLength(2)
})
it('updatePoi updates POI fields', async () => {
const created = await createPoi(db, {
lat: 40.7128,
lng: -74.0060,
label: 'Original',
})
const updated = await updatePoi(db, created.id, {
label: 'Updated',
lat: 41.7128,
})
expect(updated.label).toBe('Updated')
expect(updated.lat).toBe(41.7128)
})
it('updatePoi returns existing POI when no updates', async () => {
const created = await createPoi(db, {
lat: 40.7128,
lng: -74.0060,
label: 'Test',
})
const result = await updatePoi(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('session queries', () => {
it('getSessionById returns null for non-existent session', async () => {
const session = await getSessionById(db, 'non-existent')
expect(session).toBeNull()
})
it('createDbSession and getSessionById work together', async () => {
const sessionData = {
id: 'session-1',
user_id: 'user-1',
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 86400000).toISOString(),
}
await createDbSession(db, sessionData)
const retrieved = await getSessionById(db, 'session-1')
expect(retrieved).toBeDefined()
expect(retrieved.user_id).toBe('user-1')
})
it('deleteSession removes session', async () => {
await createDbSession(db, {
id: 'session-1',
user_id: 'user-1',
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 86400000).toISOString(),
})
await deleteSession(db, 'session-1')
const retrieved = await getSessionById(db, 'session-1')
expect(retrieved).toBeNull()
})
})
})

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest'
import { buildUpdateQuery, getAllowedColumns } from '../../server/utils/queryBuilder.js'
describe('queryBuilder', () => {
describe('buildUpdateQuery', () => {
it('builds valid UPDATE query for devices', () => {
const { query, params } = buildUpdateQuery('devices', null, {
name: 'Test Device',
lat: 40.7128,
})
expect(query).toBe('UPDATE devices SET name = ?, lat = ? WHERE id = ?')
expect(params).toEqual(['Test Device', 40.7128])
})
it('builds valid UPDATE query for users', () => {
const { query, params } = buildUpdateQuery('users', null, {
role: 'admin',
identifier: 'testuser',
})
expect(query).toBe('UPDATE users SET role = ?, identifier = ? WHERE id = ?')
expect(params).toEqual(['admin', 'testuser'])
})
it('builds valid UPDATE query for pois', () => {
const { query, params } = buildUpdateQuery('pois', null, {
label: 'Test POI',
lat: 40.7128,
lng: -74.0060,
})
expect(query).toBe('UPDATE pois SET label = ?, lat = ?, lng = ? WHERE id = ?')
expect(params).toEqual(['Test POI', 40.7128, -74.0060])
})
it('returns empty query when no updates', () => {
const { query, params } = buildUpdateQuery('devices', null, {})
expect(query).toBe('')
expect(params).toEqual([])
})
it('throws error for unknown table', () => {
expect(() => {
buildUpdateQuery('unknown_table', null, { name: 'test' })
}).toThrow('Unknown table: unknown_table')
})
it('throws error for invalid column name', () => {
expect(() => {
buildUpdateQuery('devices', null, { invalid_column: 'test' })
}).toThrow('Invalid column: invalid_column for table: devices')
})
it('prevents SQL injection attempts in column names', () => {
expect(() => {
buildUpdateQuery('devices', null, { 'name\'; DROP TABLE devices; --': 'test' })
}).toThrow('Invalid column')
})
it('allows custom allowedColumns set', () => {
const customColumns = new Set(['name', 'custom_field'])
const { query, params } = buildUpdateQuery('devices', customColumns, {
name: 'Test',
custom_field: 'value',
})
expect(query).toBe('UPDATE devices SET name = ?, custom_field = ? WHERE id = ?')
expect(params).toEqual(['Test', 'value'])
})
it('rejects columns not in custom allowedColumns', () => {
const customColumns = new Set(['name'])
expect(() => {
buildUpdateQuery('devices', customColumns, { name: 'Test', lat: 40.7128 })
}).toThrow('Invalid column: lat')
})
})
describe('getAllowedColumns', () => {
it('returns allowed columns for devices', () => {
const columns = getAllowedColumns('devices')
expect(columns).toBeInstanceOf(Set)
expect(columns.has('name')).toBe(true)
expect(columns.has('lat')).toBe(true)
expect(columns.has('invalid')).toBe(false)
})
it('returns allowed columns for users', () => {
const columns = getAllowedColumns('users')
expect(columns.has('role')).toBe(true)
expect(columns.has('identifier')).toBe(true)
})
it('returns allowed columns for pois', () => {
const columns = getAllowedColumns('pois')
expect(columns.has('label')).toBe(true)
expect(columns.has('lat')).toBe(true)
})
it('returns empty set for unknown table', () => {
const columns = getAllowedColumns('unknown')
expect(columns).toBeInstanceOf(Set)
expect(columns.size).toBe(0)
})
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from '../../server/utils/sanitize.js'
describe('sanitize', () => {
describe('sanitizeString', () => {
it('trims whitespace', () => {
expect(sanitizeString(' test ')).toBe('test')
expect(sanitizeString('\n\ttest\n\t')).toBe('test')
})
it('returns empty string for non-string input', () => {
expect(sanitizeString(null)).toBe('')
expect(sanitizeString(undefined)).toBe('')
expect(sanitizeString(123)).toBe('')
expect(sanitizeString({})).toBe('')
})
it('truncates strings exceeding max length', () => {
const longString = 'a'.repeat(2000)
expect(sanitizeString(longString, 1000).length).toBe(1000)
})
it('uses default max length', () => {
const longString = 'a'.repeat(2000)
expect(sanitizeString(longString).length).toBe(1000)
})
it('preserves valid strings', () => {
expect(sanitizeString('valid string')).toBe('valid string')
expect(sanitizeString('test123')).toBe('test123')
})
})
describe('sanitizeIdentifier', () => {
it('accepts valid identifiers', () => {
expect(sanitizeIdentifier('test123')).toBe('test123')
expect(sanitizeIdentifier('test_user')).toBe('test_user')
expect(sanitizeIdentifier('Test123')).toBe('Test123')
expect(sanitizeIdentifier('_test')).toBe('_test')
})
it('rejects invalid characters', () => {
expect(sanitizeIdentifier('test-user')).toBe('')
expect(sanitizeIdentifier('test.user')).toBe('')
expect(sanitizeIdentifier('test user')).toBe('')
expect(sanitizeIdentifier('test@user')).toBe('')
})
it('trims whitespace', () => {
expect(sanitizeIdentifier(' test123 ')).toBe('test123')
})
it('returns empty string for non-string input', () => {
expect(sanitizeIdentifier(null)).toBe('')
expect(sanitizeIdentifier(undefined)).toBe('')
expect(sanitizeIdentifier(123)).toBe('')
})
it('rejects empty strings', () => {
expect(sanitizeIdentifier('')).toBe('')
expect(sanitizeIdentifier(' ')).toBe('')
})
it('rejects strings exceeding max length', () => {
const longId = 'a'.repeat(256)
expect(sanitizeIdentifier(longId)).toBe('')
})
})
describe('sanitizeLabel', () => {
it('trims whitespace', () => {
expect(sanitizeLabel(' test label ')).toBe('test label')
})
it('truncates long labels', () => {
const longLabel = 'a'.repeat(2000)
expect(sanitizeLabel(longLabel, 500).length).toBe(500)
})
it('uses default max length', () => {
const longLabel = 'a'.repeat(2000)
expect(sanitizeLabel(longLabel).length).toBe(1000)
})
it('returns empty string for non-string input', () => {
expect(sanitizeLabel(null)).toBe('')
expect(sanitizeLabel(undefined)).toBe('')
})
it('preserves valid labels', () => {
expect(sanitizeLabel('Valid Label')).toBe('Valid Label')
expect(sanitizeLabel('Test 123')).toBe('Test 123')
})
})
})

156
test/unit/shutdown.spec.js Normal file
View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerCleanup, graceful, clearCleanup, initShutdownHandlers } from '../../server/utils/shutdown.js'
describe('shutdown', () => {
let originalExit
let exitCalls
beforeEach(() => {
clearCleanup()
exitCalls = []
originalExit = process.exit
process.exit = vi.fn((code) => {
exitCalls.push(code)
})
})
afterEach(() => {
process.exit = originalExit
clearCleanup()
})
it('registers cleanup functions', () => {
expect(() => {
registerCleanup(async () => {})
}).not.toThrow()
})
it('throws error for non-function cleanup', () => {
expect(() => {
registerCleanup('not a function')
}).toThrow('Cleanup function must be a function')
})
it('executes cleanup functions in reverse order', async () => {
const calls = []
registerCleanup(async () => {
calls.push('first')
})
registerCleanup(async () => {
calls.push('second')
})
registerCleanup(async () => {
calls.push('third')
})
await graceful()
expect(calls).toEqual(['third', 'second', 'first'])
expect(exitCalls).toEqual([0])
})
it('handles cleanup function errors gracefully', async () => {
registerCleanup(async () => {
throw new Error('Cleanup error')
})
registerCleanup(async () => {
// This should still execute
})
await graceful()
expect(exitCalls).toEqual([0])
})
it('exits with code 1 on error', async () => {
const error = new Error('Test error')
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('prevents multiple shutdowns', async () => {
let callCount = 0
registerCleanup(async () => {
callCount++
})
await graceful()
await graceful()
expect(callCount).toBe(1)
})
it('handles cleanup error during graceful shutdown', async () => {
registerCleanup(async () => {
throw new Error('Cleanup failed')
})
await graceful()
expect(exitCalls).toEqual([0])
})
it('handles error in executeCleanup catch block', async () => {
registerCleanup(async () => {
throw new Error('Test')
})
await graceful()
expect(exitCalls.length).toBeGreaterThan(0)
})
it('handles error with stack trace', async () => {
const error = new Error('Test error')
error.stack = 'Error: Test error\n at test.js:1:1'
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('handles error without stack trace', async () => {
const error = { message: 'Test error' }
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('handles timeout scenario', async () => {
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
})
const timeout = setTimeout(() => {
expect(exitCalls.length).toBeGreaterThan(0)
}, 35000)
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
clearTimeout(timeout)
})
it('covers executeCleanup early return when already shutting down', async () => {
registerCleanup(async () => {})
await graceful()
await graceful() // Second call should return early
expect(exitCalls.length).toBeGreaterThan(0)
})
it('covers initShutdownHandlers', () => {
const handlers = {}
const originalOn = process.on
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = originalOn
})
it('covers graceful catch block error path', async () => {
// Force executeCleanup to throw by making cleanup throw synchronously
registerCleanup(async () => {
throw new Error('Force error in cleanup')
})
await graceful()
expect(exitCalls.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,488 @@
import { describe, it, expect } from 'vitest'
import {
validateDevice,
validateUpdateDevice,
validateUser,
validateUpdateUser,
validatePoi,
validateUpdatePoi,
} from '../../server/utils/validation.js'
describe('validation', () => {
describe('validateDevice', () => {
it('validates valid device data', () => {
const result = validateDevice({
name: 'Test Device',
device_type: 'traffic',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'mjpeg',
})
expect(result.valid).toBe(true)
expect(result.data).toBeDefined()
expect(result.data.device_type).toBe('traffic')
})
it('rejects invalid coordinates', () => {
const result = validateDevice({
name: 'Test',
lat: 'invalid',
lng: -74.0060,
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat and lng required as finite numbers')
})
it('rejects non-object input', () => {
const result = validateDevice(null)
expect(result.valid).toBe(false)
expect(result.errors).toContain('body required')
})
it('defaults device_type to feed', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.data.device_type).toBe('feed')
})
})
describe('validateUpdateDevice', () => {
it('validates partial updates', () => {
const result = validateUpdateDevice({ name: 'Updated', lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.name).toBe('Updated')
expect(result.data.lat).toBe(40.7128)
})
it('allows empty updates', () => {
const result = validateUpdateDevice({})
expect(result.valid).toBe(true)
expect(Object.keys(result.data).length).toBe(0)
})
it('rejects invalid device_type', () => {
const result = validateUpdateDevice({ device_type: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Invalid device_type')
})
it('handles device_type undefined', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.device_type).toBeUndefined()
})
it('handles vendor null', () => {
const result = validateUpdateDevice({ vendor: null })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor empty string', () => {
const result = validateUpdateDevice({ vendor: '' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor string', () => {
const result = validateUpdateDevice({ vendor: 'Test Vendor' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBe('Test Vendor')
})
})
describe('validateUser', () => {
it('validates valid user data', () => {
const result = validateUser({
identifier: 'testuser',
password: 'password123',
role: 'admin',
})
expect(result.valid).toBe(true)
expect(result.data.identifier).toBe('testuser')
})
it('rejects missing identifier', () => {
const result = validateUser({
password: 'password123',
role: 'admin',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('identifier required')
})
it('rejects invalid role', () => {
const result = validateUser({
identifier: 'testuser',
password: 'password123',
role: 'invalid',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('role must be admin, leader, or member')
})
})
describe('validateUpdateUser', () => {
it('validates partial updates', () => {
const result = validateUpdateUser({ role: 'leader' })
expect(result.valid).toBe(true)
expect(result.data.role).toBe('leader')
})
it('rejects empty identifier', () => {
const result = validateUpdateUser({ identifier: '' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('identifier cannot be empty')
})
})
describe('validatePoi', () => {
it('validates valid POI data', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
iconType: 'flag',
})
expect(result.valid).toBe(true)
expect(result.data.lat).toBe(40.7128)
})
it('rejects invalid coordinates', () => {
const result = validatePoi({
lat: 'invalid',
lng: -74.0060,
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat and lng required as finite numbers')
})
})
describe('validateUpdatePoi', () => {
it('validates partial updates', () => {
const result = validateUpdatePoi({ label: 'Updated', lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Updated')
expect(result.data.lat).toBe(40.7128)
})
it('rejects invalid iconType', () => {
const result = validateUpdatePoi({ iconType: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Invalid iconType')
})
it('allows empty updates', () => {
const result = validateUpdatePoi({})
expect(result.valid).toBe(true)
expect(Object.keys(result.data).length).toBe(0)
})
it('rejects invalid lat', () => {
const result = validateUpdatePoi({ lat: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat must be a finite number')
})
it('rejects invalid lng', () => {
const result = validateUpdatePoi({ lng: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('lng must be a finite number')
})
})
describe('validateUpdateDevice', () => {
it('handles vendor null', () => {
const result = validateUpdateDevice({ vendor: null })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor empty string', () => {
const result = validateUpdateDevice({ vendor: '' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles config object', () => {
const result = validateUpdateDevice({ config: { key: 'value' } })
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config null', () => {
const result = validateUpdateDevice({ config: null })
expect(result.valid).toBe(true)
expect(result.data.config).toBeNull()
})
it('handles config string', () => {
const result = validateUpdateDevice({ config: '{"key":"value"}' })
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
})
describe('validateUpdateUser', () => {
it('handles empty password', () => {
const result = validateUpdateUser({ password: '' })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
it('handles undefined password', () => {
const result = validateUpdateUser({ password: undefined })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
it('validates password when provided', () => {
const result = validateUpdateUser({ password: 'newpassword' })
expect(result.valid).toBe(true)
expect(result.data.password).toBe('newpassword')
})
})
describe('validateDevice', () => {
it('handles missing stream_url', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.stream_url).toBe('')
})
it('handles invalid source_type', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
source_type: 'invalid',
})
expect(result.valid).toBe(true)
expect(result.data.source_type).toBe('mjpeg')
})
})
describe('validatePoi', () => {
it('defaults iconType to pin', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBe('pin')
})
it('handles invalid iconType', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
iconType: 'invalid',
})
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBe('pin')
})
it('validates valid POI with all fields', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
iconType: 'flag',
})
expect(result.valid).toBe(true)
expect(result.data.lat).toBe(40.7128)
expect(result.data.lng).toBe(-74.0060)
expect(result.data.label).toBe('Test POI')
expect(result.data.icon_type).toBe('flag')
})
})
describe('validateUpdateDevice', () => {
it('handles all field types', () => {
const result = validateUpdateDevice({
name: 'Test',
device_type: 'traffic',
vendor: 'Vendor',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com',
source_type: 'hls',
config: { key: 'value' },
})
expect(result.valid).toBe(true)
expect(result.data.name).toBe('Test')
expect(result.data.device_type).toBe('traffic')
expect(result.data.vendor).toBe('Vendor')
expect(result.data.lat).toBe(40.7128)
expect(result.data.lng).toBe(-74.0060)
expect(result.data.stream_url).toBe('https://example.com')
expect(result.data.source_type).toBe('hls')
expect(result.data.config).toBe('{"key":"value"}')
})
})
describe('validateUpdatePoi', () => {
it('handles all field types', () => {
const result = validateUpdatePoi({
label: 'Updated',
iconType: 'waypoint',
lat: 41.7128,
lng: -75.0060,
})
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Updated')
expect(result.data.icon_type).toBe('waypoint')
expect(result.data.lat).toBe(41.7128)
expect(result.data.lng).toBe(-75.0060)
})
it('handles partial updates', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Test')
})
})
describe('validateDevice edge cases', () => {
it('handles vendor undefined', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles config as object', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: { key: 'value' },
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config as string', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: '{"key":"value"}',
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config null', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: null,
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe(null)
})
})
describe('validateUpdateDevice edge cases', () => {
it('handles config null in updates', () => {
const result = validateUpdateDevice({ config: null })
expect(result.valid).toBe(true)
expect(result.data.config).toBeNull()
})
it('handles config undefined in updates', () => {
const result = validateUpdateDevice({ config: undefined })
expect(result.valid).toBe(true)
expect(result.data.config).toBeUndefined()
})
it('handles source_type undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.source_type).toBeUndefined()
})
it('handles lat undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lat).toBeUndefined()
})
it('handles lng undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lng).toBeUndefined()
})
it('handles stream_url undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.stream_url).toBeUndefined()
})
it('handles config undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.config).toBeUndefined()
})
})
describe('validateUpdateUser edge cases', () => {
it('handles role undefined', () => {
const result = validateUpdateUser({ identifier: 'test' })
expect(result.valid).toBe(true)
expect(result.data.role).toBeUndefined()
})
it('handles identifier undefined', () => {
const result = validateUpdateUser({ role: 'admin' })
expect(result.valid).toBe(true)
expect(result.data.identifier).toBeUndefined()
})
it('handles password undefined', () => {
const result = validateUpdateUser({ role: 'admin' })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
})
describe('validateUpdatePoi edge cases', () => {
it('handles label undefined', () => {
const result = validateUpdatePoi({ lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.label).toBeUndefined()
})
it('handles iconType undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBeUndefined()
})
it('handles lat undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lat).toBeUndefined()
})
it('handles lng undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lng).toBeUndefined()
})
})
})

View File

@@ -22,9 +22,10 @@ describe('webrtcSignaling', () => {
let sessionId
const userId = 'test-user'
beforeEach(() => {
beforeEach(async () => {
clearSessions()
sessionId = createSession(userId, 'Test').id
const session = await createSession(userId, 'Test')
sessionId = session.id
})
it('returns error when session not found', async () => {
@@ -68,4 +69,18 @@ describe('webrtcSignaling', () => {
expect(res?.type).toBe('transport-connected')
expect(res?.data?.connected).toBe(true)
})
it('returns error when transport.connect throws', async () => {
const { getTransport } = await import('../../server/utils/mediasoup.js')
getTransport.mockReturnValueOnce({
id: 'mock-transport',
connect: vi.fn().mockRejectedValue(new Error('Connection failed')),
})
await handleWebSocketMessage(userId, sessionId, 'create-transport', {})
const res = await handleWebSocketMessage(userId, sessionId, 'connect-transport', {
transportId: 'mock-transport',
dtlsParameters: { role: 'client', fingerprints: [] },
})
expect(res?.error).toBe('Connection failed')
})
})

View File

@@ -2,7 +2,7 @@ import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
exclude: ['**/node_modules/**', 'test/e2e/**'],
exclude: ['**/node_modules/**', 'test/e2e/**', 'test/integration/**'],
environment: 'nuxt',
environmentOptions: {
nuxt: {
@@ -16,10 +16,12 @@ export default defineVitestConfig({
exclude: [
'app/app.vue',
'app/pages/**/*.vue',
'app/components/KestrelMap.vue',
'app/components/**/*.vue', // UI components; covered by E2E / manual
'app/error.vue', // Error page; covered by E2E
'app/composables/useWebRTC.js', // Browser/WebRTC glue; covered by E2E
'app/composables/useLiveSessions.js', // Visibility/polling branches; covered by E2E
'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E
'app/composables/**/*.js', // Composables (polling, visibility, user); covered by E2E / integration
'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E
'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration
'**/*.spec.js',

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
/**
* Run only integration tests (start real server, assert ports).
* Usage: npm run test:integration
*/
export default defineConfig({
test: {
include: ['test/integration/**/*.spec.js'],
exclude: ['**/node_modules/**'],
environment: 'node',
testTimeout: 120000,
hookTimeout: 100000,
},
})