diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f32903d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +node_modules +.git +.github +.vscode +.DS_Store + +# Don't copy data files into the image — mount them at runtime +data + +# SQLite files +*.sqlite +*.sqlite-journal +*.sqlite-wal +*.sqlite-shm + +# Build output is generated in the Docker build stage +build +.output +.svelte-kit + +# Environment files (use docker env vars instead) +.env +.env.* + +# Tests and docs +src/**/*.test.ts +docs +vitest.config.ts \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8412f59 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Shared secret for admin endpoints. +# Required to call /api/admin/* and /api/sync?force=true. +# Pass via Authorization: Bearer header. +ADMIN_SECRET=change-me-to-a-random-string + +# Language model for generating site assessment narratives. +# Options: ollama, openai, anthropic +LLM_PROVIDER=ollama + +# Model name for the selected provider. +# Ollama default: llama3 +# OpenAI default: gpt-4o-mini +# Anthropic default: claude-3-haiku-20240307 +LLM_MODEL=llama3 + +# API keys (only needed for OpenAI and Anthropic) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= + +# Ollama base URL (only used when LLM_PROVIDER=ollama) +OLLAMA_BASE_URL=http://localhost:11434 + +# Interval in minutes between scheduled Bayesian assessments +ASSESS_INTERVAL_MINUTES=360 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0d8bdf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run test + - run: npm run build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e61e812 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Build stage — compiles native modules and builds the SvelteKit app +FROM node:22-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json .npmrc ./ + +RUN npm ci + +COPY tsconfig.json vite.config.ts vitest.config.ts ./ +COPY src/ src/ +COPY static/ static/ + +RUN npm run build + +# Production stage — minimal runtime with native module support +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +RUN apt-get remove -y python3 make g++ && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/build build/ + +RUN mkdir -p /app/data/viirs /app/data/gan + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["node", "build"] diff --git a/README.md b/README.md index e64680e..06d57f5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,77 @@ The development server runs at `http://localhost:5173`. | `npm run check` | Run Svelte type checking | | `npm run test` | Run unit tests (Vitest) | +## Deployment + +### Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `ADMIN_SECRET` | Yes | — | Shared token to protect admin endpoints. Pass via `Authorization: Bearer ` header. Without it, sync and ingest endpoints return 401. | +| `NODE_ENV` | No | `development` | Set to `production` to disable the TIFF file reader fallback. In production, VIIRS data must be pre-ingested. | +| `LLM_PROVIDER` | No | `ollama` | LLM provider for site narrative generation. Options: `ollama`, `openai`, `anthropic`. | +| `LLM_MODEL` | No | `llama3` | Model name for the selected provider. | +| `OPENAI_API_KEY` | No | — | Required only when `LLM_PROVIDER=openai`. | +| `ANTHROPIC_API_KEY` | No | — | Required only when `LLM_PROVIDER=anthropic`. | +| `OLLAMA_BASE_URL` | No | `http://localhost:11434` | Ollama server URL. | +| `ASSESS_INTERVAL_MINUTES` | No | `360` | Minutes between scheduled Bayesian assessments. | + +### Docker (recommended) + +The project includes a `Dockerfile` and `docker-compose.yml` for containerized deployment. + +```bash +# Copy your dev database for production use (skip if starting fresh) +cp duskmeter-dev.sqlite duskmeter.sqlite + +# Build and start +docker compose up --build -d + +# Ingest VIIRS data +curl -X POST "http://localhost:3000/api/admin/ingest-viirs" \ + -H "Authorization: Bearer your-secret" + +# Run initial sync +curl -X POST "http://localhost:3000/api/sync?force=true" \ + -H "Authorization: Bearer your-secret" +``` + +Volume mounts: +- `./data/viirs:/app/data/viirs` — VIIRS yearly GeoTIFF files +- `./data/gan:/app/data/gan` — Globe at Night cache +- `./duskmeter.sqlite:/app/duskmeter.sqlite` — Database + +The container auto-restarts unless explicitly stopped. Set `ADMIN_SECRET` in your environment or a `.env` file before running. + +### Standalone Node.js + +```bash +npm install +npm run build + +# Place your VIIRS yearly file +cp VNL_npp_2025_global_vcmslcfg_v2_c202604011200.average.dat.tif.gz data/viirs/ + +# Ingest and sync +curl -X POST "http://localhost:3000/api/admin/ingest-viirs" \ + -H "Authorization: Bearer your-secret" + +curl -X POST "http://localhost:3000/api/sync?force=true" \ + -H "Authorization: Bearer your-secret" + +# Start +NODE_ENV=production ADMIN_SECRET=your-secret node build +``` + +The production server listens on port 3000 by default. Set `PORT` to override. + +### Health check + +```bash +curl http://localhost:3000/api/health +# {"status":"ok","version":"0.1.0","uptime":120,"db":"connected","sites":12,"lastSync":"..."} +``` + --- ## Data sources @@ -94,7 +165,7 @@ To use real satellite data: ``` VNL_npp_{year}_global_vcmslcfg_v2_c*.cf_cvg.dat.tif.gz ``` -4. Run the ingestion endpoint: `curl -X POST http://localhost:5173/api/admin/ingest-viirs` +4. Run ingestion: `curl -X POST "http://localhost:3000/api/admin/ingest-viirs" -H "Authorization: Bearer your-secret"` 5. The ingestion pipeline decompresses the gzipped TIFF, reads pixel values at each site location, stores them in the database, and cleans up the temporary file. A deterministic synthetic fallback is used for any year without a local file. The ingest endpoint takes roughly one to two minutes per yearly file. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee0108a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + duskmeter: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - ADMIN_SECRET=${ADMIN_SECRET:-change-me} + - LLM_PROVIDER=${LLM_PROVIDER:-ollama} + - LLM_MODEL=${LLM_MODEL:-llama3} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + volumes: + # Mount your VIIRS files and Globe at Night cache + - ./data/viirs:/app/data/viirs + - ./data/gan:/app/data/gan + # Mount your existing database. If you have duskmeter-dev.sqlite from dev: + # cp duskmeter-dev.sqlite duskmeter.sqlite + # If starting fresh, this file will be created automatically. + - ./duskmeter.sqlite:/app/duskmeter.sqlite + restart: unless-stopped diff --git a/duskmeter-dev.sqlite b/duskmeter-dev.sqlite index 07ea248..a1b6ad0 100644 Binary files a/duskmeter-dev.sqlite and b/duskmeter-dev.sqlite differ diff --git a/duskmeter-dev.sqlite-shm b/duskmeter-dev.sqlite-shm index 2536354..5375b5a 100644 Binary files a/duskmeter-dev.sqlite-shm and b/duskmeter-dev.sqlite-shm differ diff --git a/duskmeter-dev.sqlite-wal b/duskmeter-dev.sqlite-wal index a356646..d44913a 100644 Binary files a/duskmeter-dev.sqlite-wal and b/duskmeter-dev.sqlite-wal differ diff --git a/duskmeter.sqlite b/duskmeter.sqlite index 374b806..1522ef7 100644 Binary files a/duskmeter.sqlite and b/duskmeter.sqlite differ diff --git a/package-lock.json b/package-lock.json index 970c3de..26c2653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/better-sqlite3": "^7.6.13", @@ -132,6 +133,26 @@ "svelte": "^5" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", @@ -456,6 +477,537 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -482,6 +1034,23 @@ "@sveltejs/kit": "^2.0.0" } }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.70.1", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", @@ -908,6 +1477,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -1144,6 +1720,13 @@ "node": ">=6" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1199,6 +1782,16 @@ "node": ">=10.13.0" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -1287,12 +1880,58 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1671,6 +2310,13 @@ "node": ">=12.20.0" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1738,6 +2384,28 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1771,6 +2439,52 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1836,6 +2550,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svelte": { "version": "5.56.8", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", diff --git a/package.json b/package.json index 61bd2ff..d0371ab 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/better-sqlite3": "^7.6.13", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8e5bad9..eae2eb0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,6 +8,59 @@ function isServerless(): boolean { return process.env.VERCEL === "1" || !!process.env.AWS_LAMBDA_FUNCTION_NAME; } +const failedAuthAttempts = new Map(); +const AUTH_WINDOW_MS = 15 * 60 * 1000; + +function getClientIp(event: { request: Request; getClientAddress?: () => string }): string { + const forwarded = event.request.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0].trim(); + try { return event.getClientAddress?.() ?? "unknown"; } catch { return "unknown"; } +} + +function verifyAdminToken(event: { request: Request }): boolean { + const secret = process.env.ADMIN_SECRET; + if (!secret) return false; + + const authHeader = event.request.headers.get("Authorization"); + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + const candidate = token ?? new URL(event.request.url).searchParams.get("token"); + if (!candidate) return false; + + if (candidate === secret) return true; + return false; +} + +function checkAuthAttempts(ip: string): boolean { + const now = Date.now(); + const record = failedAuthAttempts.get(ip); + + if (!record || now - record.firstAttempt > AUTH_WINDOW_MS) { + failedAuthAttempts.set(ip, { count: 1, firstAttempt: now }); + return true; + } + + if (record.count >= 20) return false; + + record.count++; + return true; +} + +function recordFailedAuth(ip: string): void { + const now = Date.now(); + const record = failedAuthAttempts.get(ip); + if (!record || now - record.firstAttempt > AUTH_WINDOW_MS) { + failedAuthAttempts.set(ip, { count: 1, firstAttempt: now }); + } else { + record.count++; + } +} + +function isAdminRoute(url: URL): boolean { + const isAdmin = url.pathname.startsWith("/api/admin/"); + const isForceSync = url.pathname === "/api/sync" && url.searchParams.get("force") === "true"; + return isAdmin || isForceSync; +} + export const handle: Handle = async ({ event, resolve }) => { if (!building && !started) { started = true; @@ -15,5 +68,45 @@ export const handle: Handle = async ({ event, resolve }) => { scheduleInterval(); } } - return resolve(event); + + const url = new URL(event.request.url); + + if (isAdminRoute(url)) { + const ip = getClientIp(event); + + if (!checkAuthAttempts(ip)) { + return new Response( + JSON.stringify({ error: "Too many requests" }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + } + + if (!verifyAdminToken(event)) { + recordFailedAuth(ip); + return new Response( + JSON.stringify({ error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + } + + const response = await resolve(event); + + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + const csp = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' https://fonts.gstatic.com", + "connect-src 'self' https://fonts.googleapis.com", + ].join("; "); + response.headers.set("Content-Security-Policy", csp); + + return response; }; diff --git a/src/lib/components/MapView.svelte b/src/lib/components/MapView.svelte index 63c8ad7..e4d49eb 100644 --- a/src/lib/components/MapView.svelte +++ b/src/lib/components/MapView.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/server/data-sources/bayesian.ts b/src/lib/server/data-sources/bayesian.ts index cf908b6..902e3a9 100644 --- a/src/lib/server/data-sources/bayesian.ts +++ b/src/lib/server/data-sources/bayesian.ts @@ -155,15 +155,25 @@ export function computeBayesianAssessment(input: BayesianInput): BayesianResult if (viirsResult) { posteriorMean = logTrendToPct(viirsResult.slope); - posteriorStd = Math.abs(logTrendToPct(viirsResult.slope + viirsResult.slopeStd) - posteriorMean); - posteriorInterval95Lower = logTrendToPct(viirsResult.ci95Lower); - posteriorInterval95Upper = logTrendToPct(viirsResult.ci95Upper); + posteriorStd = viirsResult.slopeStd > 0 && Math.abs(viirsResult.slope) > 0 + ? Math.abs(viirsResult.slopeStd / viirsResult.slope * posteriorMean) + : Math.abs(posteriorMean) * 0.15; + posteriorStd = Math.min(posteriorStd, Math.abs(posteriorMean) * 2); + posteriorInterval95Lower = Math.max(-50, posteriorMean - 1.96 * posteriorStd); + posteriorInterval95Upper = posteriorMean + 1.96 * posteriorStd; if (groundResult && residualPoints.length >= 5) { const residualPct = logTrendToPct(groundResult.slope); - posteriorMean = posteriorMean + residualPct; - posteriorStd = Math.sqrt(posteriorStd ** 2 + residualPct ** 2) * 0.5; - posteriorInterval95Lower = posteriorMean - 1.96 * posteriorStd; + const absResidual = Math.abs(residualPct); + const weight = Math.min(residualPoints.length / 15, 0.5); + + if (absResidual < Math.abs(posteriorMean) * 3 && absResidual < 100) { + posteriorMean = posteriorMean + residualPct * weight; + } + + posteriorStd = Math.sqrt(posteriorStd ** 2 + (residualPct * weight * 0.3) ** 2); + posteriorStd = Math.min(posteriorStd, Math.abs(posteriorMean) * 2); + posteriorInterval95Lower = Math.max(-50, posteriorMean - 1.96 * posteriorStd); posteriorInterval95Upper = posteriorMean + 1.96 * posteriorStd; } @@ -186,7 +196,7 @@ export function computeBayesianAssessment(input: BayesianInput): BayesianResult if (predSlope < 0 && residualSlope > 0) { anomalyFlags.push("diverging_trends"); } - if (residualSlope > 5) { + if (residualSlope > 5 || predSlope > 10) { anomalyFlags.push("extreme_trend"); } } else if (residualPoints.length < 5 && groundPoints.length > 0) { diff --git a/src/lib/server/data-sources/viirs.ts b/src/lib/server/data-sources/viirs.ts index 13229b5..f009527 100644 --- a/src/lib/server/data-sources/viirs.ts +++ b/src/lib/server/data-sources/viirs.ts @@ -465,6 +465,12 @@ export async function fetchViirsData(site: { } } + if (process.env.NODE_ENV === "production") { + console.warn(`[viirs] No pre-ingested data for site ${site.id ?? "unknown"} in production. Using synthetic fallback.`); + const synthetic = generateSyntheticViirs(site.lat, site.lon, site.type ?? "observatory", endYear); + return { yearly_values: synthetic, source: "synthetic", errors, availableYears: [] }; + } + const { values, availableYears: years, errors: fileErrors } = await readViirsFromFiles(site.lat, site.lon); errors.push(...fileErrors); diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index f254e1a..f32c227 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -154,6 +154,17 @@ function applySchema(instance: any) { try { instance.exec("ALTER TABLE site_metrics ADD COLUMN calibration_intercept REAL"); } catch { /* column exists */ } + + try { + const firstIngest = instance.prepare("SELECT value FROM sync_state WHERE key = 'viirs_ingested_at'").get() as { value: string } | undefined; + const staleBefore = firstIngest?.value ?? new Date(Date.now() - 7 * 86400000).toISOString(); + const deleted = instance.prepare( + "DELETE FROM alerts WHERE acknowledged = 0 AND triggered_at < ?" + ).run(staleBefore); + if (deleted.changes > 0) { + console.log(`[db] cleaned ${deleted.changes} stale alerts`); + } + } catch {} } function openCandidate(Database: any, candidate: string) { diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..a1cd7c7 --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,42 @@ +type LogLevel = "info" | "warn" | "error"; + +interface LogEntry { + ts: string; + level: LogLevel; + ctx: string; + msg: string; + data?: Record; + err?: string; +} + +function write(entry: LogEntry): void { + const line = JSON.stringify(entry); + if (entry.level === "error") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} + +function formatEntry(level: LogLevel, ctx: string, msg: string, data?: Record, err?: unknown): LogEntry { + return { + ts: new Date().toISOString(), + level, + ctx, + msg, + data: data && Object.keys(data).length > 0 ? data : undefined, + err: err instanceof Error ? err.message : err ? String(err) : undefined, + }; +} + +export const logger = { + info(ctx: string, msg: string, data?: Record) { + write(formatEntry("info", ctx, msg, data)); + }, + warn(ctx: string, msg: string, data?: Record) { + write(formatEntry("warn", ctx, msg, data)); + }, + error(ctx: string, err: unknown, data?: Record) { + write(formatEntry("error", ctx, err instanceof Error ? err.message : String(err), data, err)); + }, +}; diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index ac1e55f..57e4b89 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -1,8 +1,42 @@ import { assessAllSites } from "./assess"; import { checkAllSites } from "./alerts"; +import db from "./db"; +import { logger } from "./logger"; let timer: NodeJS.Timeout | null = null; -let running = false; + +const LOCK_KEY = "scheduler_lock"; +const LOCK_TTL_MS = 30 * 60 * 1000; + +function acquireLock(): string | null { + const instanceId = `${process.pid}-${Date.now()}`; + const now = Date.now(); + + const existing = db.prepare("SELECT value FROM sync_state WHERE key = ?").get(LOCK_KEY) as { value: string } | undefined; + if (existing) { + try { + const parsed = JSON.parse(existing.value); + if (parsed.expiresAt > now) return null; + logger.info("scheduler", "releasing stale lock", { previousOwner: parsed.instanceId }); + } catch {} + } + + const lockData = JSON.stringify({ instanceId, acquiredAt: now, expiresAt: now + LOCK_TTL_MS }); + db.prepare("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)").run(LOCK_KEY, lockData); + return instanceId; +} + +function releaseLock(instanceId: string) { + try { + const existing = db.prepare("SELECT value FROM sync_state WHERE key = ?").get(LOCK_KEY) as { value: string } | undefined; + if (existing) { + const parsed = JSON.parse(existing.value); + if (parsed.instanceId === instanceId) { + db.prepare("DELETE FROM sync_state WHERE key = ?").run(LOCK_KEY); + } + } + } catch {} +} export function scheduleInterval() { if (timer) return; @@ -15,19 +49,26 @@ export function scheduleInterval() { timer = setInterval(runScheduledTasks, minutes * 60 * 1000); timer.unref?.(); - console.log(`[scheduler] assessment every ${minutes} minutes`); + logger.info("scheduler", "started", { intervalMinutes: minutes }); + + runScheduledTasks(); } async function runScheduledTasks() { - if (running) return; - running = true; + const lockId = acquireLock(); + if (!lockId) { + logger.info("scheduler", "skipped: lock held by another instance"); + return; + } + + logger.info("scheduler", "running assessment + alert check", { instanceId: lockId }); try { - console.log("[scheduler] running scheduled assessment + alert check"); await assessAllSites(false); checkAllSites(); + logger.info("scheduler", "completed assessment + alert check", { instanceId: lockId }); } catch (e: any) { - console.error("[scheduler] failed:", e); + logger.error("scheduler", e, { instanceId: lockId }); } finally { - running = false; + releaseLock(lockId); } } diff --git a/src/lib/stores/sites.ts b/src/lib/stores/sites.ts index c22b449..7628c9f 100644 --- a/src/lib/stores/sites.ts +++ b/src/lib/stores/sites.ts @@ -6,7 +6,7 @@ export interface SiteSummary { type: string; region: string; viirsTrend: number; correctedTrend: number; bortle: number; crossingYear: number | null; - confidence: string; status: string; + confidence: string; status: "stable" | "watch" | "critical"; correctedTrendStd?: number; spectralDivergence?: number; calibrationSource?: string; diff --git a/src/routes/api/admin/ingest-viirs/+server.ts b/src/routes/api/admin/ingest-viirs/+server.ts index 5af5d97..9bce471 100644 --- a/src/routes/api/admin/ingest-viirs/+server.ts +++ b/src/routes/api/admin/ingest-viirs/+server.ts @@ -5,8 +5,19 @@ import path from "path"; import { unlink } from "fs/promises"; const VIIRS_DIR = path.join(process.cwd(), "data", "viirs"); +const INGEST_THROTTLE_MS = 24 * 60 * 60 * 1000; export async function POST() { + const lastIngest = db.prepare("SELECT value FROM sync_state WHERE key = 'viirs_ingested_at'").get() as { value: string } | undefined; + if (lastIngest) { + const elapsed = Date.now() - Date.parse(lastIngest.value); + if (elapsed < INGEST_THROTTLE_MS) { + return json({ + success: false, + error: `Ingestion was run ${Math.round(elapsed / 3600000)} hours ago. Wait at least 24 hours or clear sync_state to re-ingest.`, + }, { status: 429 }); + } + } const log: string[] = []; const errors: string[] = []; const startTime = Date.now(); @@ -50,18 +61,19 @@ export async function POST() { let tifPath: string; try { tifPath = await ensureDecompressed(filePath); - info(` Decompressed TIFF ready`); } catch (e: any) { - errors.push(`${file}: decompression failed: ${e.message}`); + info(` Decompression failed: ${e.message}`); + errors.push(`${file}: decompression failed`); continue; } let meta: Awaited>; try { meta = await parseTiffMeta(tifPath); - info(` TIFF: ${meta.width}×${meta.height}, ${meta.bitsPerSample}-bit, ${meta.pixelScaleX}°/px`); + info(` TIFF: ${meta.width}×${meta.height}, ${meta.bitsPerSample}-bit`); } catch (e: any) { - errors.push(`${file}: metadata parse failed: ${e.message}`); + info(` Metadata parse failed: ${e.message}`); + errors.push(`${file}: could not read TIFF metadata`); try { await unlink(tifPath); } catch {} continue; } @@ -72,7 +84,7 @@ export async function POST() { const radiance = await readPixel(tifPath, site.lat, site.lon, meta); if (radiance == null || !Number.isFinite(radiance) || radiance <= 0) { - errors.push(`${site.name}: no valid radiance at (${site.lat}, ${site.lon})`); + errors.push(`${site.name}: no valid radiance value`); continue; } @@ -81,8 +93,8 @@ export async function POST() { inserted.push({ site: site.name, year, radiance: rounded }); siteCount++; - } catch (e: any) { - errors.push(`${site.name}: read failed: ${e.message}`); + } catch { + errors.push(`${site.name}: processing failed`); } } @@ -108,11 +120,10 @@ export async function POST() { duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`, }); } catch (e: any) { + console.error(`[ingest] failed: ${e?.message ?? e}`); return json({ success: false, - error: e instanceof Error ? e.message : String(e), - log, - errors, + error: "Internal error", }, { status: 500 }); } } diff --git a/src/routes/api/alerts/check/+server.ts b/src/routes/api/alerts/check/+server.ts index d5f33df..f7ae64b 100644 --- a/src/routes/api/alerts/check/+server.ts +++ b/src/routes/api/alerts/check/+server.ts @@ -1,9 +1,32 @@ import { json } from "@sveltejs/kit"; +import db from "$lib/server/db"; import { checkAllSites } from "$lib/server/alerts"; +export function GET() { + try { + const counts = db.prepare(` + SELECT severity, COUNT(*) as count FROM alerts + WHERE acknowledged = 0 + GROUP BY severity + `).all() as { severity: string; count: number }[]; + + const total = counts.reduce((s, r) => s + r.count, 0); + const lastCheck = db.prepare("SELECT value FROM sync_state WHERE key = 'last_alert_check_at'").get() as { value: string } | undefined; + + return json({ + total, + bySeverity: Object.fromEntries(counts.map(r => [r.severity, r.count])), + lastCheckedAt: lastCheck?.value ?? null, + }); + } catch (e: any) { + return json({ error: "Internal error" }, { status: 500 }); + } +} + export function POST() { try { const result = checkAllSites(); + db.prepare("INSERT OR REPLACE INTO sync_state (key, value) VALUES ('last_alert_check_at', ?)").run(new Date().toISOString()); return json(result); } catch (e: any) { console.error(`[api/alerts/check] failed: ${e?.message ?? e}`); diff --git a/src/routes/api/assess-all/+server.ts b/src/routes/api/assess-all/+server.ts index d5ddeef..a094446 100644 --- a/src/routes/api/assess-all/+server.ts +++ b/src/routes/api/assess-all/+server.ts @@ -1,10 +1,33 @@ import { json } from "@sveltejs/kit"; +import db from "$lib/server/db"; import { assessAllSites } from "$lib/server/assess"; +export async function GET() { + try { + const lastAssess = db.prepare("SELECT value FROM sync_state WHERE key = 'last_assess_at'").get() as { value: string } | undefined; + const count = (db.prepare("SELECT COUNT(*) as c FROM ai_assessments").get() as { c: number }).c; + return json({ + assessed: count, + lastAssessedAt: lastAssess?.value ?? null, + }); + } catch (e: any) { + return json({ error: "Internal error" }, { status: 500 }); + } +} + export async function POST({ url }) { try { + const lastAssess = db.prepare("SELECT value FROM sync_state WHERE key = 'last_assess_at'").get() as { value: string } | undefined; + if (lastAssess) { + const elapsed = Date.now() - Date.parse(lastAssess.value); + if (elapsed < 30 * 60 * 1000) { + return json({ error: "Assessment ran recently. Wait 30 minutes between runs." }, { status: 429 }); + } + } + const runLlm = url.searchParams.get("llm") === "true"; const results = await assessAllSites(runLlm); + db.prepare("INSERT OR REPLACE INTO sync_state (key, value) VALUES ('last_assess_at', ?)").run(new Date().toISOString()); return json({ assessed: results.length, results }); } catch (e: any) { console.error(`[api/assess-all] failed: ${e?.message ?? e}`); diff --git a/src/routes/api/export/+server.ts b/src/routes/api/export/+server.ts index 7e4265d..0701c7d 100644 --- a/src/routes/api/export/+server.ts +++ b/src/routes/api/export/+server.ts @@ -6,7 +6,9 @@ export async function GET({ url }) { try { const format = url.searchParams.get("format") ?? "csv"; const sites = db.prepare(` - SELECT s.*, m.viirs_trend, m.corrected_trend, m.bortle, m.crossing_year, m.ground_confidence + SELECT s.*, m.viirs_trend, m.corrected_trend, m.bortle, m.crossing_year, m.ground_confidence, + m.calibration_source, m.corrected_trend_std, m.spectral_divergence, + m.calibration_slope, m.calibration_intercept FROM sites s LEFT JOIN site_metrics m ON m.site_id = s.id `).all() as any[]; @@ -20,14 +22,27 @@ export async function GET({ url }) { region: s.region, viirs_trend_pct_per_year: s.viirs_trend ?? "", corrected_trend_pct_per_year: s.corrected_trend ?? "", + corrected_trend_std: s.corrected_trend_std ?? "", + spectral_divergence: s.spectral_divergence ?? "", bortle_class: s.bortle ?? "", projected_crossing_year: s.crossing_year ?? "", + calibration_source: s.calibration_source ?? "population", + calibration_slope: s.calibration_slope ?? "", + calibration_intercept: s.calibration_intercept ?? "", ground_confidence: s.ground_confidence ?? "", exported_at: new Date().toISOString(), })); if (format === "json") { - return json(rows); + const jsonRows = rows.map(r => ({ + ...r, + projected_crossing_year: r.projected_crossing_year === "" ? null : r.projected_crossing_year, + calibration_slope: r.calibration_slope === "" ? 0 : r.calibration_slope, + calibration_intercept: r.calibration_intercept === "" ? 0 : r.calibration_intercept, + corrected_trend_std: r.corrected_trend_std === "" ? 0 : r.corrected_trend_std, + spectral_divergence: r.spectral_divergence === "" ? 0 : r.spectral_divergence, + })); + return json(jsonRows); } return new Response(stringifyCsv(rows), { diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..ba445de --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -0,0 +1,32 @@ +import { json } from "@sveltejs/kit"; +import db from "$lib/server/db"; + +export function GET() { + let dbStatus = "connected"; + let siteCount = 0; + let lastSync: string | null = null; + let lastIngest: string | null = null; + + try { + const syncState = db.prepare("SELECT value FROM sync_state WHERE key = 'last_sync_at'").get() as { value: string } | undefined; + lastSync = syncState?.value ?? null; + + const ingestState = db.prepare("SELECT value FROM sync_state WHERE key = 'viirs_ingested_at'").get() as { value: string } | undefined; + lastIngest = ingestState?.value ?? null; + + const count = db.prepare("SELECT COUNT(*) as c FROM sites").get() as { c: number }; + siteCount = count.c; + } catch { + dbStatus = "error"; + } + + return json({ + status: dbStatus === "connected" ? "ok" : "degraded", + version: "0.1.0", + uptime: Math.floor(process.uptime()), + db: dbStatus, + sites: siteCount, + lastSync, + lastIngest, + }); +} diff --git a/src/routes/api/sites/[id]/assess/+server.ts b/src/routes/api/sites/[id]/assess/+server.ts index 20c6aa9..564a3da 100644 --- a/src/routes/api/sites/[id]/assess/+server.ts +++ b/src/routes/api/sites/[id]/assess/+server.ts @@ -1,14 +1,25 @@ import { json } from "@sveltejs/kit"; +import db from "$lib/server/db"; import { assessSite } from "$lib/server/assess"; import { checkSite } from "$lib/server/alerts"; export async function POST({ params, url }) { try { + const lastKey = `assess_${params.id}_at`; + const last = db.prepare("SELECT value FROM sync_state WHERE key = ?").get(lastKey) as { value: string } | undefined; + if (last) { + const elapsed = Date.now() - Date.parse(last.value); + if (elapsed < 5 * 60 * 1000) { + return json({ error: "Assessment ran recently. Wait 5 minutes between runs for this site." }, { status: 429 }); + } + } + const runLlm = url.searchParams.get("llm") === "true"; const result = await assessSite(params.id, runLlm); if (!result) { return json({ error: "Site not found" }, { status: 404 }); } + db.prepare("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)").run(lastKey, new Date().toISOString()); const alerts = checkSite(params.id); return json({ ...result, alertsCreated: alerts.created, alerts: alerts.alerts }); } catch (e: any) { diff --git a/src/routes/api/sites/[id]/calibration/+server.ts b/src/routes/api/sites/[id]/calibration/+server.ts index 58a7dbc..a3ff73b 100644 --- a/src/routes/api/sites/[id]/calibration/+server.ts +++ b/src/routes/api/sites/[id]/calibration/+server.ts @@ -21,7 +21,11 @@ export async function POST({ params, request }) { const readings = body.readings; if (!Array.isArray(readings) || readings.length < 5) { - return json({ error: "At least 5 calibration readings required for a reliable fit" }, { status: 400 }); + return json({ error: "At least 5 calibration readings required" }, { status: 400 }); + } + + if (readings.length > 500) { + return json({ error: "Maximum 500 calibration readings allowed" }, { status: 400 }); } const viirsRows = db.prepare( @@ -37,8 +41,13 @@ export async function POST({ params, request }) { const skyBrightnessValues: number[] = []; for (const r of readings) { let radiance: number | null = null; - if (r.sqm != null && r.sqm > 0) radiance = sqmToRadiance(r.sqm); - else if (r.limiting_magnitude != null && r.limiting_magnitude > 0) radiance = limitingMagToRadiance(r.limiting_magnitude); + if (r.sqm != null && r.sqm > 0) { + if (r.sqm < 16 || r.sqm > 24) continue; + radiance = sqmToRadiance(r.sqm); + } else if (r.limiting_magnitude != null && r.limiting_magnitude > 0) { + if (r.limiting_magnitude < 2 || r.limiting_magnitude > 8) continue; + radiance = limitingMagToRadiance(r.limiting_magnitude); + } if (radiance == null || radiance <= 0) continue; skyBrightnessValues.push(radiance); } diff --git a/vite.config.ts b/vite.config.ts index 52d5257..571d043 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite';