From a5b108d9fa04d1c56181b3a26479b0eed4b7a990 Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 17 Jul 2026 10:33:20 -0400 Subject: [PATCH] Initial Commit --- README.md | 68 +- backend/.env.example | 10 + backend/.gitignore | 4 + backend/README.md | 89 + backend/package-lock.json | 1685 +++++++++++++++++ backend/package.json | 25 + backend/src/api/admin.ts | 112 ++ backend/src/api/auth.ts | 45 + backend/src/api/password.ts | 19 + backend/src/api/public.ts | 27 + backend/src/index.ts | 57 + backend/src/inference/ollama-provider.ts | 60 + backend/src/inference/provider.ts | 6 + backend/src/ingestion/adapters/api.ts | 45 + backend/src/ingestion/adapters/base.ts | 48 + backend/src/ingestion/adapters/rss.ts | 70 + backend/src/ingestion/adapters/telegram.ts | 17 + backend/src/ingestion/poller.ts | 49 + backend/src/pipeline/clustering.ts | 56 + backend/src/pipeline/embedding.ts | 41 + backend/src/pipeline/image-selection.ts | 37 + backend/src/pipeline/publish.ts | 179 ++ backend/src/pipeline/synthesis.ts | 42 + backend/src/queue/eventsRecap.ts | 72 + backend/src/queue/priorityQueue.ts | 111 ++ backend/src/queue/retention.ts | 64 + backend/src/queue/scheduler.ts | 67 + backend/src/storage/db/articles.ts | 115 ++ backend/src/storage/db/auth.ts | 53 + backend/src/storage/db/categories.ts | 16 + backend/src/storage/db/contentItems.ts | 100 + backend/src/storage/db/events.ts | 81 + backend/src/storage/db/index.ts | 196 ++ backend/src/storage/db/logs.ts | 62 + backend/src/storage/db/settings.ts | 65 + backend/src/storage/db/sources.ts | 97 + backend/src/storage/db/tags.ts | 95 + backend/src/storage/db/types.ts | 121 ++ backend/src/storage/media/index.ts | 80 + backend/tsconfig.json | 16 + frontend/.gitignore | 23 + frontend/.npmrc | 1 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 42 + frontend/package-lock.json | 1390 ++++++++++++++ frontend/package.json | 23 + frontend/src/app.d.ts | 13 + frontend/src/app.html | 24 + frontend/src/lib/adminApi.ts | 92 + frontend/src/lib/adminTypes.ts | 74 + frontend/src/lib/api.ts | 28 + frontend/src/lib/assets/favicon.svg | 1 + .../src/lib/components/ArticleCard.svelte | 76 + .../src/lib/components/ThemeToggle.svelte | 62 + .../components/admin/ConnectionsTab.svelte | 133 ++ .../src/lib/components/admin/EventsTab.svelte | 172 ++ .../src/lib/components/admin/LogsTab.svelte | 169 ++ .../src/lib/components/admin/MergeTab.svelte | 259 +++ .../src/lib/components/admin/ModelsTab.svelte | 127 ++ .../lib/components/admin/RetentionTab.svelte | 189 ++ .../lib/components/admin/SaveStatus.svelte | 26 + .../lib/components/admin/SourcesTab.svelte | 246 +++ frontend/src/lib/config.ts | 22 + frontend/src/lib/format.ts | 9 + frontend/src/lib/index.ts | 1 + frontend/src/lib/styles/app.css | 83 + frontend/src/lib/theme.svelte.ts | 38 + frontend/src/lib/types.ts | 44 + frontend/src/routes/+layout.svelte | 119 ++ frontend/src/routes/+page.svelte | 124 ++ frontend/src/routes/+page.ts | 19 + frontend/src/routes/admin/+layout.svelte | 50 + frontend/src/routes/admin/+layout.ts | 7 + frontend/src/routes/admin/login/+page.svelte | 79 + .../src/routes/admin/settings/+page.svelte | 89 + frontend/src/routes/admin/settings/+page.ts | 34 + frontend/src/routes/article/[id]/+page.svelte | 187 ++ frontend/src/routes/article/[id]/+page.ts | 21 + .../src/routes/category/[name]/+page.svelte | 49 + frontend/src/routes/category/[name]/+page.ts | 12 + frontend/static/robots.txt | 3 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 20 + mock-backend/admin-data.js | 147 ++ mock-backend/data.js | 224 +++ mock-backend/package-lock.json | 889 +++++++++ mock-backend/package.json | 17 + mock-backend/server.js | 121 ++ 88 files changed, 9802 insertions(+), 1 deletion(-) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/api/admin.ts create mode 100644 backend/src/api/auth.ts create mode 100644 backend/src/api/password.ts create mode 100644 backend/src/api/public.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/inference/ollama-provider.ts create mode 100644 backend/src/inference/provider.ts create mode 100644 backend/src/ingestion/adapters/api.ts create mode 100644 backend/src/ingestion/adapters/base.ts create mode 100644 backend/src/ingestion/adapters/rss.ts create mode 100644 backend/src/ingestion/adapters/telegram.ts create mode 100644 backend/src/ingestion/poller.ts create mode 100644 backend/src/pipeline/clustering.ts create mode 100644 backend/src/pipeline/embedding.ts create mode 100644 backend/src/pipeline/image-selection.ts create mode 100644 backend/src/pipeline/publish.ts create mode 100644 backend/src/pipeline/synthesis.ts create mode 100644 backend/src/queue/eventsRecap.ts create mode 100644 backend/src/queue/priorityQueue.ts create mode 100644 backend/src/queue/retention.ts create mode 100644 backend/src/queue/scheduler.ts create mode 100644 backend/src/storage/db/articles.ts create mode 100644 backend/src/storage/db/auth.ts create mode 100644 backend/src/storage/db/categories.ts create mode 100644 backend/src/storage/db/contentItems.ts create mode 100644 backend/src/storage/db/events.ts create mode 100644 backend/src/storage/db/index.ts create mode 100644 backend/src/storage/db/logs.ts create mode 100644 backend/src/storage/db/settings.ts create mode 100644 backend/src/storage/db/sources.ts create mode 100644 backend/src/storage/db/tags.ts create mode 100644 backend/src/storage/db/types.ts create mode 100644 backend/src/storage/media/index.ts create mode 100644 backend/tsconfig.json create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/adminApi.ts create mode 100644 frontend/src/lib/adminTypes.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/components/ArticleCard.svelte create mode 100644 frontend/src/lib/components/ThemeToggle.svelte create mode 100644 frontend/src/lib/components/admin/ConnectionsTab.svelte create mode 100644 frontend/src/lib/components/admin/EventsTab.svelte create mode 100644 frontend/src/lib/components/admin/LogsTab.svelte create mode 100644 frontend/src/lib/components/admin/MergeTab.svelte create mode 100644 frontend/src/lib/components/admin/ModelsTab.svelte create mode 100644 frontend/src/lib/components/admin/RetentionTab.svelte create mode 100644 frontend/src/lib/components/admin/SaveStatus.svelte create mode 100644 frontend/src/lib/components/admin/SourcesTab.svelte create mode 100644 frontend/src/lib/config.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/index.ts create mode 100644 frontend/src/lib/styles/app.css create mode 100644 frontend/src/lib/theme.svelte.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/src/routes/+page.ts create mode 100644 frontend/src/routes/admin/+layout.svelte create mode 100644 frontend/src/routes/admin/+layout.ts create mode 100644 frontend/src/routes/admin/login/+page.svelte create mode 100644 frontend/src/routes/admin/settings/+page.svelte create mode 100644 frontend/src/routes/admin/settings/+page.ts create mode 100644 frontend/src/routes/article/[id]/+page.svelte create mode 100644 frontend/src/routes/article/[id]/+page.ts create mode 100644 frontend/src/routes/category/[name]/+page.svelte create mode 100644 frontend/src/routes/category/[name]/+page.ts create mode 100644 frontend/static/robots.txt create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 mock-backend/admin-data.js create mode 100644 mock-backend/data.js create mode 100644 mock-backend/package-lock.json create mode 100644 mock-backend/package.json create mode 100644 mock-backend/server.js diff --git a/README.md b/README.md index 0c9d148..9e4a003 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,68 @@ -# homefeed +# Homefeed — frontend, real backend, and mock backend +``` +frontend/ SvelteKit app — the actual site (homepage, category pages, article view, admin panel) +backend/ The real backend — Node.js/TypeScript, SQLite, RSS/API ingestion, Ollama-backed synthesis +mock-backend/ Tiny Express server serving dummy articles — useful for pure frontend UI work without Ollama running +``` + +Both `backend/` and `mock-backend/` implement the identical `/api/feed`, +`/api/article/:id`, `/api/tags`, `/api/events`, `/api/admin/*` contract — the frontend +doesn't know or care which one it's talking to. Switch between them by changing +`VITE_BACKEND_URL` in `frontend/.env`. + +## Running the real backend + +```bash +cd backend +cp .env.example .env # set ADMIN_PASSWORD at minimum +npm install +npm run dev +``` + +See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter, +image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama +isn't reachable. + +## Running the mock backend instead (frontend-only work, no Ollama needed) + +Two terminals: + +```bash +# Terminal 1 — mock backend (http://localhost:4000) +cd mock-backend +npm install +npm start + +# Terminal 2 — frontend (http://localhost:5173) +cd frontend +npm install +npm run dev +``` + +Open http://localhost:5173. + +## What's implemented + +- **Homepage** (`/`) — hero story, Local section, Business and Tech rails +- **Category pages** (`/category/local`, `/category/world`, etc.) — full listing per category, `local` maps to the Philadelphia geo filter +- **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer +- **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier +- **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`). +- **Admin panel** (`/admin/settings`) — six tabs, all wired to the mock backend's `/api/admin/*` routes: + - **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry + - **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds + - **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog + - **Retention** — published-article and raw-item age presets, storage cap with FIFO note and usage bar + - **Tracked events** — list, create, toggle active/paused, delete + - **Connections** — the asymmetric pair: frontend→backend URL (saved to *this browser* via `localStorage`, not a backend setting) and backend→AI-service host/port (a real backend setting, saved via `/api/admin/settings`) + +## Mock data + +`mock-backend/data.js` has ~10 dummy articles covering: a 4-source merge with a follow-up (`art-1` → `art-2`, same `threadId`), single-source articles across Business/Tech, and a Local section with a mix of merged, single-source, and video items — enough variety to sanity-check every card/badge state in the design. + +Timestamps are generated relative to `Date.now()` (see `hoursAgo()` in `data.js`) rather than hardcoded, so "time ago" labels stay sensible no matter when you run this. + +## Connecting to the real backend later + +The frontend never hardcodes `localhost:4000` — see `frontend/src/lib/config.ts`. It reads `VITE_BACKEND_URL` (set in `frontend/.env`) or a value saved via `setBackendUrl()`. Pointing this project at the real backend instead of the mock is a one-line change, not a rewrite — swap the URL in `.env` and everything else keeps working, since both servers implement the same `/api/feed`, `/api/article/:id`, `/api/tags`, `/api/events` contract from `homefeed-data-schema.md`. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ff2a53b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +PORT=4000 +FRONTEND_ORIGIN=http://localhost:5173 +DB_PATH=./data/homefeed.db +MEDIA_DIR=./data/media + +# Seeded once on first run — change the password after logging in. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-immediately + +NODE_ENV=development diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2dd2f3e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +data +.env diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6f03ad0 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,89 @@ +# Homefeed backend + +Node.js + TypeScript, per `project-structure.md`. Ingests RSS/API/Telegram sources, +clusters and synthesizes them into merged articles via a self-hosted Ollama instance, +and serves the same `/api/feed`, `/api/article/:id`, `/api/tags`, `/api/events` +contract the frontend already consumes from the mock backend — plus the full +`/api/admin/*` surface behind session auth. + +## Setup + +```bash +cp .env.example .env +# edit .env — at minimum set ADMIN_PASSWORD to something real +npm install +npm run dev +``` + +Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of +the mock backend and everything else keeps working unchanged — same API contract. + +You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in +the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at +minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g. +`qwen2.5:7b-instruct-q4_K_M`) pulled. Without it reachable, ingestion still runs, but +the synthesis tick quietly skips each cycle until the AI service comes back — nothing +crashes, articles just don't get published. + +## Behavior before Ollama is set up + +Per the "assume Ollama arrives after the backend launches" requirement: ingestion and +publishing don't wait for it. + +- **Adding a source polls it immediately**, not on the next scheduler tick — you see + results right away instead of waiting up to a minute. +- **With no AI service reachable**, the synthesis tick falls back to publishing each + item directly once it clears the hold-before-publish window — no rewriting, no + cross-source merging, no tags (there's no LLM to extract them yet), but the page + populates instead of staying empty. Media (images) still downloads normally, since + that never needed AI in the first place. +- **Once Ollama becomes reachable**, the real pipeline (embed → cluster → synthesize → + tag) takes back over for anything ingested from that point on. Articles already + published via the passthrough path aren't retroactively rewritten or merged — they + stand as they are, but new related coverage can still link to them as a follow-up + via the normal tag-based thread detection. +- **Tracked-event recaps are the one thing that still waits for AI** — summarizing a + day's worth of Telegram messages isn't something worth faking with a passthrough; + those simply don't fire until the AI service is available. + +## What's real vs. stubbed + +Built and verified end-to-end (see the test run in this conversation — real SQLite, +real RSS parsing, real HTTP calls to a stub Ollama server, real media download to +disk, real tag dedup across separate synthesis calls): + +- SQLite schema + repository layer for every entity in `homefeed-data-schema.md` +- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the + configured frontend origin) protecting all `/api/admin/*` routes +- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter + (configurable field mapping) +- Poller respecting per-source poll intervals +- Embedding → clustering → synthesis → tag extraction/dedup → image selection → + media download → publish pipeline, talking to Ollama over HTTP +- Priority queue ordering by admin-ranked category before clustering +- Retention sweep (age-based for both raw items and published articles, size-based + FIFO cap, tag expiry) running hourly +- Full admin API: settings, sources, tracked events, live model catalog from Ollama, + AI service status + +**Stubbed / simplified, worth knowing before relying on it:** + +- **Telegram adapter** (`ingestion/adapters/telegram.ts`) is a stub — logs a warning + and returns nothing. Needs a real implementation (grammY, per the architecture doc) + keyed off `source.config.telegramChannelId`. +- **Image selection** picks the highest-resolution candidate rather than using a + vision model to judge relevance — cheap and deterministic, but doesn't catch a + technically-large image that's actually a poor choice (watermarked, wrong subject). + A vision-model pass is a natural upgrade once this needs to be smarter. +- **Follow-up thread detection** matches on shared tags within a 3-day lookback rather + than comparing article-level embeddings directly. Works, but a cluster with no tags + extracted can't be linked to a prior thread at all. +- **Storage-cap FIFO eviction** deletes oldest articles but doesn't yet cascade-delete + their associated media rows/files in the same pass — there's a comment in + `queue/retention.ts` explaining the gap and the backstop that partially covers it. +- **`node:sqlite`** is still an experimental Node API. If a future Node version changes + its behavior, `better-sqlite3` is a drop-in-shaped alternative (same synchronous + `.prepare().run()/.get()/.all()` style). + +None of these block running the backend end-to-end — they're the specific spots worth +hardening next, roughly in the order listed. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..32174e3 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1685 @@ +{ + "name": "homefeed-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "homefeed-backend", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@fastify/cookie": "^11.1.1", + "@fastify/cors": "^11.3.0", + "fastify": "^5.10.0", + "rss-parser": "^3.13.0" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.0", + "typescript": "^7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz", + "integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz", + "integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f108826 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,25 @@ +{ + "name": "homefeed-backend", + "version": "1.0.0", + "description": "Homefeed backend — ingestion, clustering, synthesis, and the public/admin API", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch --env-file=.env src/index.ts", + "build": "tsc", + "start": "node --experimental-sqlite dist/index.js", + "typecheck": "tsc --noEmit" + }, + "license": "UNLICENSED", + "dependencies": { + "@fastify/cookie": "^11.1.1", + "@fastify/cors": "^11.3.0", + "fastify": "^5.10.0", + "rss-parser": "^3.13.0" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.0", + "typescript": "^7.0.2" + } +} diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts new file mode 100644 index 0000000..f9c6966 --- /dev/null +++ b/backend/src/api/admin.ts @@ -0,0 +1,112 @@ +import type { FastifyInstance } from 'fastify'; +import * as settingsDb from '../storage/db/settings.js'; +import * as sourcesDb from '../storage/db/sources.js'; +import * as eventsDb from '../storage/db/events.js'; +import * as categoriesDb from '../storage/db/categories.js'; +import { OllamaProvider } from '../inference/ollama-provider.js'; +import { pollSourceNow } from '../ingestion/poller.js'; +import { logger, listLogs } from '../storage/db/logs.js'; + +export async function registerAdminRoutes(app: FastifyInstance) { + // --- Settings --- + app.get('/api/admin/settings', async () => { + const settings = settingsDb.getSettings(); + return { ...settings, categoryPriority: categoriesDb.listCategories() }; + }); + + app.patch('/api/admin/settings', async (req) => { + const body = req.body as any; + if (body.categoryPriority) { + categoriesDb.setCategoryOrder(body.categoryPriority); + delete body.categoryPriority; + } + const settings = settingsDb.updateSettings(body); + return { ...settings, categoryPriority: categoriesDb.listCategories() }; + }); + + // --- Sources --- + app.get('/api/admin/sources', async () => sourcesDb.listSources()); + + app.post('/api/admin/sources', async (req, reply) => { + const created = sourcesDb.createSource(req.body as any); + // Poll immediately rather than waiting for the next scheduler tick (up to 1 minute) + // — the admin adding a feed expects to see it start working right away. + pollSourceNow(created).catch((err) => logger.error('poller', `Immediate poll failed for "${created.name}": ${err.message}`)); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/sources/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = sourcesDb.updateSource(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/sources/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + sourcesDb.deleteSource(id); + return reply.code(204).send(); + }); + + // Manual "poll now" — the refresh icon on each source in the admin panel. + app.post('/api/admin/sources/:id/poll', async (req, reply) => { + const { id } = req.params as { id: string }; + const source = sourcesDb.getSource(id); + if (!source) return reply.code(404).send({ error: 'not found' }); + + const ingested = await pollSourceNow(source); + logger.info('poller', `Manual poll of "${source.name}" — ${ingested} new item(s)`); + return { ingested, source: sourcesDb.getSource(id) }; + }); + + // --- Tracked events --- + app.get('/api/admin/events', async () => eventsDb.listEvents()); + + app.post('/api/admin/events', async (req, reply) => { + const created = eventsDb.createEvent(req.body as any); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/events/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = eventsDb.updateEvent(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/events/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + eventsDb.deleteEvent(id); + return reply.code(204).send(); + }); + + // --- Models / AI service (fetched live from the configured Ollama host) --- + app.get('/api/admin/models', async (_req, reply) => { + const settings = settingsDb.getSettings(); + const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort); + try { + const models = await provider.listModels(); + // Ollama doesn't distinguish task type, so the catalog surfaces the full list + // for each dropdown — the admin picks which installed model to use for what. + return { embedding: models, image: models, synthesis: models }; + } catch (err) { + return reply.code(502).send({ error: `AI service unreachable: ${(err as Error).message}` }); + } + }); + + app.get('/api/admin/ai-status', async () => { + const settings = settingsDb.getSettings(); + const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort); + const connected = await provider.isReachable(); + return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null }; + }); + + // --- Logs --- + app.get('/api/admin/logs', async (req) => { + const { level, limit } = req.query as { level?: string; limit?: string }; + return listLogs({ + level: level === 'info' || level === 'warn' || level === 'error' ? level : undefined, + limit: limit ? Number(limit) : undefined + }); + }); +} diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts new file mode 100644 index 0000000..e791c2f --- /dev/null +++ b/backend/src/api/auth.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from 'fastify'; +import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js'; +import { verifyPassword } from './password.js'; + +const SESSION_COOKIE = 'homefeed_session'; + +export async function registerAuth(app: FastifyInstance) { + app.post('/api/admin/login', async (req, reply) => { + const { username, password } = req.body as { username?: string; password?: string }; + if (!username || !password) return reply.code(400).send({ error: 'username and password required' }); + + const user = getAdminUserByUsername(username); + if (!user || !verifyPassword(password, user.password_hash)) { + // Deliberately generic — doesn't reveal whether the username exists. + return reply.code(401).send({ error: 'invalid credentials' }); + } + + const session = createSession(req.ip ?? null); + reply.setCookie(SESSION_COOKIE, session.id, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + path: '/', + expires: new Date(session.expiresAt) + }); + return { ok: true }; + }); + + app.post('/api/admin/logout', async (req, reply) => { + const sessionId = req.cookies[SESSION_COOKIE]; + if (sessionId) deleteSession(sessionId); + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + return { ok: true }; + }); + + // Guards every /api/admin/* route except login itself. + app.addHook('preHandler', async (req, reply) => { + if (!req.url.startsWith('/api/admin/') || req.url === '/api/admin/login') return; + + const sessionId = req.cookies[SESSION_COOKIE]; + if (!sessionId || !isSessionValid(sessionId)) { + return reply.code(401).send({ error: 'unauthorized' }); + } + }); +} diff --git a/backend/src/api/password.ts b/backend/src/api/password.ts new file mode 100644 index 0000000..82a9a7c --- /dev/null +++ b/backend/src/api/password.ts @@ -0,0 +1,19 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +const KEY_LEN = 64; + +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEY_LEN); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const [saltHex, hashHex] = stored.split(':'); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(hashHex, 'hex'); + const actual = scryptSync(password, salt, KEY_LEN); + if (actual.length !== expected.length) return false; + return timingSafeEqual(actual, expected); +} diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts new file mode 100644 index 0000000..58460ea --- /dev/null +++ b/backend/src/api/public.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; +import * as articlesDb from '../storage/db/articles.js'; +import * as tagsDb from '../storage/db/tags.js'; +import * as eventsDb from '../storage/db/events.js'; + +export async function registerPublicRoutes(app: FastifyInstance) { + app.get('/api/feed', async (req) => { + const { category, geo, eventId, tag } = req.query as Record; + return articlesDb.queryFeed({ category, geo, eventId, tag }); + }); + + app.get('/api/article/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const article = articlesDb.getArticle(id); + if (!article) return reply.code(404).send({ error: 'not found' }); + return article; + }); + + app.get('/api/tags', async () => { + return tagsDb.listActiveTags(); + }); + + app.get('/api/events', async () => { + // Public fields only — sourceIds, cadenceTime etc. stay admin-only. + return eventsDb.listEvents().map((e) => ({ id: e.id, name: e.name, active: e.active, cadence: e.cadence })); + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..4dfc800 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,57 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import cookie from '@fastify/cookie'; +import fs from 'node:fs'; +import path from 'node:path'; +import { migrate } from './storage/db/index.js'; +import { ensureAdminUserSeeded } from './storage/db/auth.js'; +import { registerAuth } from './api/auth.js'; +import { registerPublicRoutes } from './api/public.js'; +import { registerAdminRoutes } from './api/admin.js'; +import { startScheduler } from './queue/scheduler.js'; +import { logger } from './storage/db/logs.js'; + +const PORT = Number(process.env.PORT) || 4000; +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; +const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; + +async function main() { + migrate(); + ensureAdminUserSeeded( + process.env.ADMIN_USERNAME || 'admin', + process.env.ADMIN_PASSWORD || 'change-me-immediately' + ); + + const app = Fastify({ logger: false }); + + // Cross-origin is expected — see project-structure.md "Cross-origin and security + // implications". Not a wildcard: only the configured frontend origin is allowed. + await app.register(cors, { origin: FRONTEND_ORIGIN, credentials: true }); + await app.register(cookie); + + await registerAuth(app); + await registerPublicRoutes(app); + await registerAdminRoutes(app); + + // Locally hosted media (see storage/media) — served directly rather than via a + // heavier static-file plugin, since this is a small, flat directory. + app.get('/media/:filename', async (req, reply) => { + const { filename } = req.params as { filename: string }; + if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); + const filePath = path.join(MEDIA_DIR, filename); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + return reply.send(fs.createReadStream(filePath)); + }); + + app.get('/health', async () => ({ ok: true })); + + await app.listen({ port: PORT, host: '0.0.0.0' }); + logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`); + + startScheduler(); +} + +main().catch((err) => { + console.error('[server] Fatal startup error:', err); + process.exit(1); +}); diff --git a/backend/src/inference/ollama-provider.ts b/backend/src/inference/ollama-provider.ts new file mode 100644 index 0000000..b735c5f --- /dev/null +++ b/backend/src/inference/ollama-provider.ts @@ -0,0 +1,60 @@ +import type { InferenceProvider } from './provider.js'; + +/** + * Talks to a self-hosted Ollama instance over HTTP. Address is a normal backend + * setting (GlobalSettings.aiServiceHost/Port), editable via the admin panel — + * see the Connections tab and "AI service connection" in the schema doc. + */ +export class OllamaProvider implements InferenceProvider { + constructor( + private host: string, + private port: number + ) {} + + private base(): string { + return `${this.host}:${this.port}`; + } + + async generate(prompt: string, opts: { model?: string; system?: string } = {}): Promise { + const res = await fetch(`${this.base()}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: opts.model, + prompt, + system: opts.system, + stream: false + }) + }); + if (!res.ok) throw new Error(`Ollama generate failed: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { response: string }; + return data.response; + } + + async embed(text: string, opts: { model?: string } = {}): Promise { + const res = await fetch(`${this.base()}/api/embeddings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: opts.model, prompt: text }) + }); + if (!res.ok) throw new Error(`Ollama embed failed: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { embedding: number[] }; + return data.embedding; + } + + async listModels(): Promise { + const res = await fetch(`${this.base()}/api/tags`); + if (!res.ok) throw new Error(`Ollama listModels failed: ${res.status}`); + const data = (await res.json()) as { models: { name: string }[] }; + return data.models.map((m) => m.name); + } + + async isReachable(): Promise { + try { + const res = await fetch(`${this.base()}/api/tags`, { signal: AbortSignal.timeout(3000) }); + return res.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/inference/provider.ts b/backend/src/inference/provider.ts new file mode 100644 index 0000000..d994b1d --- /dev/null +++ b/backend/src/inference/provider.ts @@ -0,0 +1,6 @@ +export interface InferenceProvider { + generate(prompt: string, opts?: { model?: string; system?: string }): Promise; + embed(text: string, opts?: { model?: string }): Promise; + listModels(): Promise; + isReachable(): Promise; +} diff --git a/backend/src/ingestion/adapters/api.ts b/backend/src/ingestion/adapters/api.ts new file mode 100644 index 0000000..63dc26b --- /dev/null +++ b/backend/src/ingestion/adapters/api.ts @@ -0,0 +1,45 @@ +import type { Source } from '../../storage/db/types.js'; +import type { SourceAdapter, FetchedItem } from './base.js'; + +function getPath(obj: unknown, path: string): unknown { + return path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? (acc as any)[key] : undefined), obj); +} + +/** + * For arbitrary JSON APIs. `source.config.fieldMap` tells the adapter where to find + * the array of items and each field within an item, e.g.: + * { itemsPath: "articles", title: "headline", link: "url", summary: "description", publishedAt: "date" } + */ +export const apiAdapter: SourceAdapter = { + async fetch(source: Source): Promise { + if (!source.url) return []; + const fieldMap = (source.config.fieldMap as Record) ?? {}; + const headers = (source.config.authHeaders as Record) ?? {}; + + const res = await fetch(source.url, { headers }); + if (!res.ok) throw new Error(`API fetch failed: ${res.status}`); + const data = await res.json(); + + const rawItems = fieldMap.itemsPath ? getPath(data, fieldMap.itemsPath) : data; + if (!Array.isArray(rawItems)) return []; + + const items: FetchedItem[] = []; + for (const raw of rawItems) { + const title = getPath(raw, fieldMap.title ?? 'title'); + const link = getPath(raw, fieldMap.link ?? 'link'); + if (typeof title !== 'string' || typeof link !== 'string') continue; + + items.push({ + title, + summary: String(getPath(raw, fieldMap.summary ?? 'summary') ?? '').slice(0, 500), + body: null, + images: [], + videos: [], + link, + publishedAt: String(getPath(raw, fieldMap.publishedAt ?? 'publishedAt') ?? new Date().toISOString()), + raw + }); + } + return items; + } +}; diff --git a/backend/src/ingestion/adapters/base.ts b/backend/src/ingestion/adapters/base.ts new file mode 100644 index 0000000..9a586f9 --- /dev/null +++ b/backend/src/ingestion/adapters/base.ts @@ -0,0 +1,48 @@ +import type { Source, ContentItem } from '../../storage/db/types.js'; + +export interface FetchedItem { + title: string; + summary: string; + body: string | null; + images: { url: string; caption?: string; width?: number; height?: number }[]; + videos: { url: string; provider?: string; embedHtml?: string }[]; + link: string; + publishedAt: string; + raw: unknown; +} + +export interface SourceAdapter { + /** Fetches and normalizes new items from this source. Should not throw on individual bad items. */ + fetch(source: Source): Promise; +} + +export function toContentItem(source: Source, item: FetchedItem): Omit { + return { + sourceId: source.id, + type: 'article', + title: item.title, + summary: item.summary, + body: item.body, + images: item.images, + videos: item.videos, + link: item.link, + publishedAt: item.publishedAt, + fetchedAt: new Date().toISOString(), + tags: [], + geo: source.category.some((c) => c.toLowerCase().startsWith('local')) ? extractGeoTag(source) : null, + embedding: null, + eventId: null, + clusterId: null, + raw: item.raw + }; +} + +function extractGeoTag(source: Source): string | null { + // Sources assigned to a LocalRegion carry a "Local: X" category by convention in the + // admin panel (see SourcesTab). Real geo-tagging comes from LocalRegion.sourceIds + // membership; this is a lightweight fallback derived from the category label. + const match = source.category.find((c) => c.toLowerCase().startsWith('local')); + if (!match) return null; + const parts = match.split(':'); + return parts[1]?.trim().toLowerCase().replace(/\s+/g, '-') ?? null; +} diff --git a/backend/src/ingestion/adapters/rss.ts b/backend/src/ingestion/adapters/rss.ts new file mode 100644 index 0000000..4b74708 --- /dev/null +++ b/backend/src/ingestion/adapters/rss.ts @@ -0,0 +1,70 @@ +import Parser from 'rss-parser'; +import type { Source } from '../../storage/db/types.js'; +import type { SourceAdapter, FetchedItem } from './base.js'; + +type RssItem = Parser.Item & { + 'media:content'?: any; + enclosure?: { url?: string; type?: string }; +}; + +const parser = new Parser, RssItem>({ + customFields: { + item: [['media:content', 'media:content']] + } +}); + +function extractImages(item: RssItem): FetchedItem['images'] { + const images: FetchedItem['images'] = []; + + const media = item['media:content']; + if (media) { + const entries = Array.isArray(media) ? media : [media]; + for (const m of entries) { + if (m?.$?.url) { + images.push({ + url: m.$.url, + width: m.$.width ? Number(m.$.width) : undefined, + height: m.$.height ? Number(m.$.height) : undefined + }); + } + } + } + + if (item.enclosure?.url && item.enclosure.type?.startsWith('image/')) { + images.push({ url: item.enclosure.url }); + } + + return images; +} + +function extractVideos(item: RssItem): FetchedItem['videos'] { + if (item.enclosure?.url && item.enclosure.type?.startsWith('video/')) { + return [{ url: item.enclosure.url }]; + } + return []; +} + +export const rssAdapter: SourceAdapter = { + async fetch(source: Source): Promise { + if (!source.url) return []; + + const feed = await parser.parseURL(source.url); + const items: FetchedItem[] = []; + + for (const item of feed.items) { + if (!item.link || !item.title) continue; + items.push({ + title: item.title, + summary: (item.contentSnippet || item.summary || '').slice(0, 500), + body: item.content ?? null, + images: extractImages(item), + videos: extractVideos(item), + link: item.link, + publishedAt: item.isoDate ?? item.pubDate ?? new Date().toISOString(), + raw: item + }); + } + + return items; + } +}; diff --git a/backend/src/ingestion/adapters/telegram.ts b/backend/src/ingestion/adapters/telegram.ts new file mode 100644 index 0000000..3b55a7a --- /dev/null +++ b/backend/src/ingestion/adapters/telegram.ts @@ -0,0 +1,17 @@ +import type { Source } from '../../storage/db/types.js'; +import type { SourceAdapter, FetchedItem } from './base.js'; +import { logger } from '../../storage/db/logs.js'; + +/** + * TODO: real implementation. Telegram channels are read via the Bot API (grammY, + * per project-structure.md) using `getUpdates` or a channel-history fetch, keyed by + * `source.config.telegramChannelId`. Left as a stub — satisfies the SourceAdapter + * interface so the poller and admin panel can already list/configure Telegram sources + * (e.g. for the "Iran war" tracked event example) before this is filled in. + */ +export const telegramAdapter: SourceAdapter = { + async fetch(source: Source): Promise { + logger.warn('telegram', `Adapter not yet implemented — skipping source "${source.name}"`); + return []; + } +}; diff --git a/backend/src/ingestion/poller.ts b/backend/src/ingestion/poller.ts new file mode 100644 index 0000000..20474e8 --- /dev/null +++ b/backend/src/ingestion/poller.ts @@ -0,0 +1,49 @@ +import * as sourcesDb from '../storage/db/sources.js'; +import * as contentItemsDb from '../storage/db/contentItems.js'; +import { logger } from '../storage/db/logs.js'; +import { rssAdapter } from './adapters/rss.js'; +import { telegramAdapter } from './adapters/telegram.js'; +import { apiAdapter } from './adapters/api.js'; +import { toContentItem, type SourceAdapter } from './adapters/base.js'; +import type { Source } from '../storage/db/types.js'; + +const adapters: Record = { + rss: rssAdapter, + telegram: telegramAdapter, + api: apiAdapter, + custom: apiAdapter +}; + +export async function pollDueSources(defaultIntervalMinutes: number): Promise { + const due = sourcesDb.sourcesDueForPoll(defaultIntervalMinutes); + let ingested = 0; + for (const source of due) { + ingested += await pollOne(source); + } + return ingested; +} + +/** Polls a single source immediately, bypassing its schedule — used right after a source is created. */ +export async function pollSourceNow(source: Source): Promise { + return pollOne(source); +} + +async function pollOne(source: Source): Promise { + const adapter = adapters[source.type]; + let ingested = 0; + try { + const fetched = await adapter.fetch(source); + for (const item of fetched) { + if (contentItemsDb.existsByLink(item.link)) continue; + contentItemsDb.insertContentItem(toContentItem(source, item)); + ingested++; + } + sourcesDb.markPolled(source.id, null); + logger.info('poller', `Polled "${source.name}" (${source.type}) — ${ingested} new item(s)`); + } catch (err) { + const message = (err as Error).message; + logger.error('poller', `Source "${source.name}" failed: ${message}`); + sourcesDb.markPolled(source.id, message); + } + return ingested; +} diff --git a/backend/src/pipeline/clustering.ts b/backend/src/pipeline/clustering.ts new file mode 100644 index 0000000..f0e5bd3 --- /dev/null +++ b/backend/src/pipeline/clustering.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto'; +import { cosineSimilarity } from './embedding.js'; +import type { ContentItem } from '../storage/db/types.js'; + +/** + * Maps the admin-facing 1-5 strictness slider to a cosine similarity threshold. + * 1 (loose) merges more readily; 5 (strict) requires near-duplicate stories. + */ +export function strictnessToThreshold(strictness: number): number { + const table: Record = { 1: 0.55, 2: 0.65, 3: 0.75, 4: 0.85, 5: 0.92 }; + return table[strictness] ?? 0.75; +} + +export interface Cluster { + id: string; + items: ContentItem[]; + centroid: number[]; +} + +function average(vectors: number[][]): number[] { + const len = vectors[0].length; + const sum = new Array(len).fill(0); + for (const v of vectors) for (let i = 0; i < len; i++) sum[i] += v[i]; + return sum.map((s) => s / vectors.length); +} + +/** + * Greedy single-pass clustering: items already events-only (event-assigned items are + * excluded upstream — see TrackedEvent handling) get grouped against existing cluster + * centroids, or start a new cluster if nothing is similar enough. + */ +export function clusterItems(items: ContentItem[], strictness: number): Cluster[] { + const threshold = strictnessToThreshold(strictness); + const clusters: Cluster[] = []; + + for (const item of items) { + if (!item.embedding) continue; + + let best: { cluster: Cluster; score: number } | null = null; + for (const cluster of clusters) { + const score = cosineSimilarity(item.embedding, cluster.centroid); + if (score >= threshold && (!best || score > best.score)) { + best = { cluster, score }; + } + } + + if (best) { + best.cluster.items.push(item); + best.cluster.centroid = average(best.cluster.items.map((i) => i.embedding!)); + } else { + clusters.push({ id: randomUUID(), items: [item], centroid: item.embedding }); + } + } + + return clusters; +} diff --git a/backend/src/pipeline/embedding.ts b/backend/src/pipeline/embedding.ts new file mode 100644 index 0000000..1d1235c --- /dev/null +++ b/backend/src/pipeline/embedding.ts @@ -0,0 +1,41 @@ +import type { InferenceProvider } from '../inference/provider.js'; +import { logger } from '../storage/db/logs.js'; +import * as contentItems from '../storage/db/contentItems.js'; +import type { ContentItem } from '../storage/db/types.js'; + +/** Computes and stores an embedding for any content item that doesn't have one yet. */ +export async function embedPendingItems( + provider: InferenceProvider, + model: string, + items: ContentItem[] +): Promise { + const withEmbeddings: ContentItem[] = []; + for (const item of items) { + if (item.embedding) { + withEmbeddings.push(item); + continue; + } + try { + const embedding = await provider.embed(`${item.title}\n${item.summary}`, { model }); + contentItems.setEmbedding(item.id, embedding); + withEmbeddings.push({ ...item, embedding }); + } catch (err) { + logger.error('synthesis', `Embedding failed for item ${item.id}: ${(err as Error).message}`); + } + } + return withEmbeddings; +} + +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0; + let dot = 0, + normA = 0, + normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] ** 2; + normB += b[i] ** 2; + } + if (normA === 0 || normB === 0) return 0; + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +} diff --git a/backend/src/pipeline/image-selection.ts b/backend/src/pipeline/image-selection.ts new file mode 100644 index 0000000..40e5b63 --- /dev/null +++ b/backend/src/pipeline/image-selection.ts @@ -0,0 +1,37 @@ +import type { ContentItem } from '../storage/db/types.js'; + +export interface SelectedImage { + url: string; + sourceItemId: string; + selectionReason: string; +} + +/** + * Picks the best candidate image across a cluster's items. Currently a fast heuristic + * (largest reported resolution, first-seen as tiebreak) rather than a vision-model call — + * cheap, deterministic, and good enough for most cases. A vision model (via the same + * Ollama connection) could replace this later for cases where resolution alone picks + * a poor crop or an image with a watermark; that requires downloading image bytes to + * pass to the model, which is a larger change than swapping this function's internals. + */ +export function selectBestImage(items: ContentItem[]): SelectedImage | null { + let best: { image: ContentItem['images'][number]; item: ContentItem; area: number } | null = null; + + for (const item of items) { + for (const image of item.images) { + const area = (image.width ?? 0) * (image.height ?? 0); + if (!best || area > best.area) { + best = { image, item, area }; + } + } + } + + if (!best) return null; + + const reason = + best.area > 0 + ? `Highest-resolution candidate (${best.image.width}x${best.image.height}) among ${items.length} source(s)` + : `First available image among ${items.length} source(s)`; + + return { url: best.image.url, sourceItemId: best.item.id, selectionReason: reason }; +} diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts new file mode 100644 index 0000000..8813fbf --- /dev/null +++ b/backend/src/pipeline/publish.ts @@ -0,0 +1,179 @@ +import { randomUUID } from 'node:crypto'; +import type { InferenceProvider } from '../inference/provider.js'; +import type { Cluster } from './clustering.js'; +import { synthesizeArticle } from './synthesis.js'; +import { selectBestImage } from './image-selection.js'; +import { downloadAndStore, promoteToPublished } from '../storage/media/index.js'; +import { logger } from '../storage/db/logs.js'; +import * as articles from '../storage/db/articles.js'; +import * as tags from '../storage/db/tags.js'; +import * as sources from '../storage/db/sources.js'; +import type { GlobalSettings, MergedArticle, ContentItem } from '../storage/db/types.js'; + +const FOLLOW_UP_LOOKBACK_DAYS = 3; + +function uniqueCategories(items: ContentItem[]): string[] { + const cats = new Set(); + for (const item of items) { + const source = sources.getSource(item.sourceId); + for (const cat of source?.category ?? []) cats.add(cat); + } + return [...cats]; +} + +/** Takes the first line of the synthesized body as a working title until a dedicated title-generation step exists. */ +function deriveTitle(body: string): string { + const firstLine = body.split('\n')[0]; + return firstLine.length > 100 ? firstLine.slice(0, 97) + '…' : firstLine; +} + +/** + * Publishes a single item as-is, with no AI calls at all — used when the AI service + * isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives + * after the backend launches" requirement). No rewriting, no tag extraction, no + * embedding. This is deliberately a lesser version of the real pipeline: once Ollama + * is available, newly-ingested items get the full embed/cluster/synthesize treatment + * and can be linked as follow-ups to these passthrough articles via the normal + * tag-based thread detection — but these earlier articles aren't retroactively + * rewritten or merged with anything after the fact. + */ +export async function publishDirect(item: ContentItem): Promise { + const category = uniqueCategories([item]); + const now = new Date().toISOString(); + + let heroImage: MergedArticle['heroImage'] = null; + if (item.images.length > 0) { + const stored = await downloadAndStore(item.images[0].url, 'published', {}); + heroImage = stored + ? { url: stored.servedPath, sourceItemId: item.id, selectionReason: 'Only available image (no AI service configured yet)' } + : { url: item.images[0].url, sourceItemId: item.id, selectionReason: 'Only available image (no AI service configured yet)' }; + } + + const video = item.videos[0] ? { url: item.videos[0].url, provider: item.videos[0].provider, sourceItemId: item.id } : null; + + return articles.insertArticle({ + title: item.title, + body: item.body || item.summary, + heroImage, + video, + category, + geo: item.geo, + eventId: item.eventId, + sourceCount: 1, + sources: [ + { + itemId: item.id, + sourceName: sources.getSource(item.sourceId)?.name ?? 'Unknown source', + link: item.link, + publishedAt: item.publishedAt + } + ], + publishedAt: now, + updatedAt: now, + mergeConfidence: 1.0, + tags: [], // no LLM available to extract tags yet — backfilling these later is a reasonable future improvement + threadId: randomUUID(), + previousArticleId: null, + nextArticleId: null + }); +} + +/** + * Publishing is always automatic — there's no draft/review state (see schema doc). + * A cluster of size 1 publishes as-is via the same path; synthesizeArticle lightly + * rewrites rather than merges when there's only one source. + */ +export async function publishCluster( + provider: InferenceProvider, + settings: GlobalSettings, + cluster: Cluster, + opts: { eventId?: string } = {} +): Promise { + const items = cluster.items; + + const { body, tagLabels } = await synthesizeArticle(provider, settings.selectedModels.synthesis, items); + + const resolvedTags = []; + for (const label of tagLabels) { + try { + const embedding = await provider.embed(label, { model: settings.selectedModels.embedding }); + resolvedTags.push(tags.resolveOrCreateTag(label, embedding, settings.tagDedupThreshold)); + } catch (err) { + logger.error('synthesis', `Tag embedding failed for "${label}": ${(err as Error).message}`); + } + } + const tagIds = resolvedTags.map((t) => t.id); + + const selectedImage = selectBestImage(items); + let heroImage: MergedArticle['heroImage'] = null; + let storedMediaId: string | null = null; + if (selectedImage) { + const stored = await downloadAndStore(selectedImage.url, 'published', {}); + if (stored) { + storedMediaId = stored.id; + heroImage = { url: stored.servedPath, sourceItemId: selectedImage.sourceItemId, selectionReason: selectedImage.selectionReason }; + } else { + heroImage = selectedImage; // fall back to the hotlinked URL if the download failed, rather than losing the image entirely + } + } + const videoItem = items.find((i) => i.videos.length > 0); + const video = videoItem + ? { url: videoItem.videos[0].url, provider: videoItem.videos[0].provider, sourceItemId: videoItem.id } + : null; + + const category = uniqueCategories(items); + const geo = items.find((i) => i.geo)?.geo ?? null; + + const itemSources = items.map((item) => ({ + itemId: item.id, + sourceName: sources.getSource(item.sourceId)?.name ?? 'Unknown source', + link: item.link, + publishedAt: item.publishedAt + })); + + // Follow-up detection: only links to a previous article if enough time AND enough + // new corroborating sources have passed the admin's thresholds (see MergeTab). + // Otherwise this cluster just becomes its own independent thread — holding items + // in a "pending, not yet enough to follow up" state is a reasonable future + // improvement but adds a queue state this MVP doesn't have yet. + const previous = tagIds.length > 0 ? articles.findRecentArticleByTags(tagIds, FOLLOW_UP_LOOKBACK_DAYS) : null; + let threadId: string = randomUUID(); + let previousArticleId: string | null = null; + + if (previous) { + const hoursSince = (Date.now() - new Date(previous.publishedAt).getTime()) / 3600_000; + const previousLinks = new Set(previous.sources.map((s) => s.link)); + const newSourceCount = itemSources.filter((s) => !previousLinks.has(s.link)).length; + + if (hoursSince >= settings.followUpMinHoursSinceLast && newSourceCount >= settings.followUpMinNewSources) { + threadId = previous.threadId; + previousArticleId = previous.id; + } + } + + const now = new Date().toISOString(); + const article = articles.insertArticle({ + title: deriveTitle(body), + body, + heroImage, + video, + category, + geo, + eventId: opts.eventId ?? items[0]?.eventId ?? null, + sourceCount: items.length, + sources: itemSources, + publishedAt: now, + updatedAt: now, + mergeConfidence: items.length > 1 ? 0.8 : 1.0, + tags: tagIds, + threadId, + previousArticleId, + nextArticleId: null + }); + + if (storedMediaId) { + promoteToPublished(storedMediaId, article.id); + } + + return article; +} diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts new file mode 100644 index 0000000..0480fb0 --- /dev/null +++ b/backend/src/pipeline/synthesis.ts @@ -0,0 +1,42 @@ +import type { InferenceProvider } from '../inference/provider.js'; +import type { ContentItem } from '../storage/db/types.js'; + +const TAG_DELIMITER = '---TAGS---'; + +const SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write a single original article that: +- Attributes specific claims to the outlet that reported them (e.g. "Reuters reported...", "AP notes...") +- Does not copy phrasing verbatim from any source +- Stays neutral and factual, without editorializing +- Is 2-4 short paragraphs + +If only one source is provided, lightly rewrite it in your own words rather than merging. + +After the article, on a new line, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this article is about. If nothing salient qualifies, leave the tag line empty.`; + +export interface SynthesisResult { + body: string; + tagLabels: string[]; +} + +function buildPrompt(items: ContentItem[]): string { + return items + .map((item, i) => `Source ${i + 1} (${item.sourceId}):\nTitle: ${item.title}\nSummary: ${item.summary}`) + .join('\n\n'); +} + +export async function synthesizeArticle( + provider: InferenceProvider, + model: string, + items: ContentItem[] +): Promise { + const prompt = buildPrompt(items); + const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT }); + + const [body, tagSection] = raw.split(TAG_DELIMITER); + const tagLabels = (tagSection ?? '') + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0 && t.length < 60); + + return { body: body.trim(), tagLabels }; +} diff --git a/backend/src/queue/eventsRecap.ts b/backend/src/queue/eventsRecap.ts new file mode 100644 index 0000000..703f6da --- /dev/null +++ b/backend/src/queue/eventsRecap.ts @@ -0,0 +1,72 @@ +import type { InferenceProvider } from '../inference/provider.js'; +import * as eventsDb from '../storage/db/events.js'; +import * as contentItemsDb from '../storage/db/contentItems.js'; +import { embedPendingItems } from '../pipeline/embedding.js'; +import { publishCluster } from '../pipeline/publish.js'; +import { randomUUID } from 'node:crypto'; +import type { GlobalSettings } from '../storage/db/types.js'; +import { logger } from '../storage/db/logs.js'; + +function isDue(event: ReturnType[number]): boolean { + if (event.cadence === 'continuous') return true; // handled every cycle like normal clustering, just scoped to its sources + + const now = new Date(); + const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null; + + if (event.cadence === 'hourly') { + return !last || now.getTime() - last.getTime() >= 3600_000; + } + + if (event.cadence === 'daily') { + if (!event.cadenceTime) return false; + const [h, m] = event.cadenceTime.split(':').map(Number); + const scheduledToday = new Date(now); + scheduledToday.setHours(h, m, 0, 0); + const alreadyRecappedToday = last && last.toDateString() === now.toDateString(); + return now >= scheduledToday && !alreadyRecappedToday; + } + + return false; +} + +export async function runEventRecaps(provider: InferenceProvider, settings: GlobalSettings): Promise { + const events = eventsDb.listActiveEvents(); + let published = 0; + + for (const event of events) { + if (event.sourceIds.length === 0 || !isDue(event)) continue; + + const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString(); + const items = contentItemsDb.unclusteredItemsForSources(event.sourceIds, since); + if (items.length === 0) continue; + + const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, items); + const withEmbeddings = embedded.filter((i) => i.embedding); + if (withEmbeddings.length === 0) continue; + + try { + const article = await publishCluster( + provider, + settings, + { + id: randomUUID(), + items: withEmbeddings, + centroid: withEmbeddings[0].embedding! + }, + { eventId: event.id } + ); + + contentItemsDb.assignCluster( + withEmbeddings.map((i) => i.id), + article.id + ); + eventsDb.markRecapped(event.id); + published++; + logger.info('events', `Published recap for "${event.name}" from ${withEmbeddings.length} item(s)`); + } catch (err) { + logger.error('events', `Recap failed for "${event.name}": ${(err as Error).message}`); + } + } + + return published; +} diff --git a/backend/src/queue/priorityQueue.ts b/backend/src/queue/priorityQueue.ts new file mode 100644 index 0000000..9ba1b22 --- /dev/null +++ b/backend/src/queue/priorityQueue.ts @@ -0,0 +1,111 @@ +import type { InferenceProvider } from '../inference/provider.js'; +import * as contentItemsDb from '../storage/db/contentItems.js'; +import * as sourcesDb from '../storage/db/sources.js'; +import * as categoriesDb from '../storage/db/categories.js'; +import * as eventsDb from '../storage/db/events.js'; +import { embedPendingItems } from '../pipeline/embedding.js'; +import { clusterItems } from '../pipeline/clustering.js'; +import { publishCluster, publishDirect } from '../pipeline/publish.js'; +import { logger } from '../storage/db/logs.js'; +import type { GlobalSettings, ContentItem } from '../storage/db/types.js'; + +function primaryCategoryRank(item: ContentItem, rankByName: Map): number { + const source = sourcesDb.getSource(item.sourceId); + const cats = source?.category ?? []; + let best = Infinity; + for (const cat of cats) { + // Category names on sources can be free text (e.g. "Local: Philadelphia") — + // match on the leading segment against the admin-ranked category list. + const leading = cat.split(':')[0].trim(); + const rank = rankByName.get(leading.toLowerCase()); + if (rank !== undefined && rank < best) best = rank; + } + return best; +} + +/** + * Fallback for when the AI service isn't reachable yet — publishes eligible items + * directly (no clustering across sources, no rewriting) rather than leaving pages + * empty until Ollama is set up. Still respects category priority and the + * hold-before-publish window; just skips everything that requires an AI call. + */ +export async function runPassthroughCycle(settings: GlobalSettings): Promise { + const eventSourceIds = eventsDb.listActiveEvents().flatMap((e) => e.sourceIds); + const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds); + if (items.length === 0) return 0; + + const categories = categoriesDb.listCategories(); + const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank])); + const ranked = items + .map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) })) + .sort((a, b) => a.rank - b.rank) + .map((r) => r.item); + + const holdMs = settings.holdBeforePublishMinutes * 60_000; + let published = 0; + + for (const item of ranked) { + const ready = Date.now() - new Date(item.fetchedAt).getTime() >= holdMs; + if (!ready) continue; + + try { + const article = await publishDirect(item); + contentItemsDb.assignCluster([item.id], article.id); + published++; + logger.info('synthesis', `Published "${article.title}" directly (no AI available)`); + } catch (err) { + logger.error('synthesis', `Passthrough publish failed for "${item.title}": ${(err as Error).message}`); + } + } + + return published; +} + +/** + * One pass of the synthesis queue: cluster whatever's unclustered (excluding items + * belonging to tracked-event sources, which are handled by eventsRecap.ts instead), + * ordered by admin-defined category priority, and publish clusters that have cleared + * the hold-before-publish window. + */ +export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise { + const eventSourceIds = eventsDb.listActiveEvents().flatMap((e) => e.sourceIds); + const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds); + if (items.length === 0) return 0; + + const categories = categoriesDb.listCategories(); + const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank])); + + const ranked = items + .map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) })) + .sort((a, b) => a.rank - b.rank) + .map((r) => r.item); + + const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, ranked); + const clusters = clusterItems(embedded, settings.mergeStrictness); + + const holdMs = settings.holdBeforePublishMinutes * 60_000; + let published = 0; + + for (const cluster of clusters) { + const earliestFetch = Math.min(...cluster.items.map((i) => new Date(i.fetchedAt).getTime())); + const ready = Date.now() - earliestFetch >= holdMs; + if (!ready) continue; // left unclustered — reconsidered next cycle, possibly with more corroborating items + + try { + const article = await publishCluster(provider, settings, cluster); + contentItemsDb.assignCluster( + cluster.items.map((i) => i.id), + cluster.id + ); + published++; + logger.info( + 'synthesis', + `Published "${article.title}" from ${cluster.items.length} source(s)` + ); + } catch (err) { + logger.error('synthesis', `Failed to publish cluster: ${(err as Error).message}`); + } + } + + return published; +} diff --git a/backend/src/queue/retention.ts b/backend/src/queue/retention.ts new file mode 100644 index 0000000..c2737a0 --- /dev/null +++ b/backend/src/queue/retention.ts @@ -0,0 +1,64 @@ +import * as articlesDb from '../storage/db/articles.js'; +import * as contentItemsDb from '../storage/db/contentItems.js'; +import * as tagsDb from '../storage/db/tags.js'; +import * as eventsDb from '../storage/db/events.js'; +import { deleteCandidateMediaOlderThan, totalStorageBytes } from '../storage/media/index.js'; +import { logger } from '../storage/db/logs.js'; +import type { GlobalSettings } from '../storage/db/types.js'; + +/** + * One sweep, evaluating both dimensions from the schema doc: age-based limits and the + * size-based FIFO cap. Whichever fires, oldest-first. Tracked events can override the + * published-article age limit (e.g. "Iran war" recaps kept forever) — see EventsTab. + */ +export function runRetentionSweep(settings: GlobalSettings) { + const { retention } = settings; + + if (retention.rawItemMaxAgeDays != null) { + const stale = contentItemsDb.itemsOlderThan(retention.rawItemMaxAgeDays); + if (stale.length > 0) { + contentItemsDb.deleteContentItems(stale.map((i) => i.id)); + logger.info('retention', `Pruned ${stale.length} raw item(s) older than ${retention.rawItemMaxAgeDays}d`); + } + } + + if (retention.publishedArticleMaxAgeDays != null) { + const eventOverrides = new Map(eventsDb.listEvents().map((e) => [e.id, e.retentionOverrideDays])); + const stale = articlesDb.articlesOlderThan(retention.publishedArticleMaxAgeDays); + for (const article of stale) { + const override = article.eventId ? eventOverrides.get(article.eventId) : undefined; + if (override === null) continue; // explicit "Forever" override + if (override !== undefined) { + const overrideCutoff = Date.now() - override * 86_400_000; + if (new Date(article.publishedAt).getTime() > overrideCutoff) continue; + } + articlesDb.deleteArticle(article.id); + } + } + + deleteCandidateMediaOlderThan(retention.rawItemMaxAgeDays ?? 7); + + if (retention.storageCapEnabled) { + enforceStorageCap(retention.storageCapValue, retention.storageCapUnit); + } + + const expiredTags = tagsDb.expireStaleTags(settings.tagExpiryDays); + if (expiredTags > 0) logger.info('retention', `Expired ${expiredTags} stale tag(s)`); +} + +function enforceStorageCap(capValue: number, unit: 'MB' | 'GB') { + const capBytes = capValue * (unit === 'GB' ? 1024 * 1024 * 1024 : 1024 * 1024); + let used = totalStorageBytes(); + if (used <= capBytes) return; + + // FIFO: delete oldest published articles (and their media, via cascade at the DB + // level being absent here — media rows are cleaned up by the candidate sweep above; + // published-tier media tied to a deleted article becomes orphaned and is swept next + // cycle once its downloaded_at also ages past raw-item retention as a backstop). + const oldest = articlesDb.queryFeed({}).reverse(); // oldest first + for (const article of oldest) { + if (used <= capBytes) break; + articlesDb.deleteArticle(article.id); + used = totalStorageBytes(); + } +} diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts new file mode 100644 index 0000000..783dd20 --- /dev/null +++ b/backend/src/queue/scheduler.ts @@ -0,0 +1,67 @@ +import { pollDueSources } from '../ingestion/poller.js'; +import { runSynthesisCycle, runPassthroughCycle } from './priorityQueue.js'; +import { runEventRecaps } from './eventsRecap.js'; +import { runRetentionSweep } from './retention.js'; +import { OllamaProvider } from '../inference/ollama-provider.js'; +import * as settingsDb from '../storage/db/settings.js'; +import { pruneExpiredSessions } from '../storage/db/auth.js'; +import { logger } from '../storage/db/logs.js'; + +const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency +const SYNTHESIS_TICK_MS = 60_000; +const RETENTION_TICK_MS = 60 * 60_000; // hourly + +export function startScheduler() { + const provider = () => { + const s = settingsDb.getSettings(); + return new OllamaProvider(s.aiServiceHost, s.aiServicePort); + }; + + setInterval(async () => { + try { + const settings = settingsDb.getSettings(); + const ingested = await pollDueSources(settings.defaultPollIntervalMinutes); + if (ingested > 0) logger.info('scheduler', `Poll tick: ingested ${ingested} new item(s)`); + } catch (err) { + logger.error('scheduler', `Poll tick failed: ${(err as Error).message}`); + } + }, POLL_TICK_MS); + + setInterval(async () => { + try { + const settings = settingsDb.getSettings(); + const p = provider(); + + if (!(await p.isReachable())) { + // Ollama isn't set up yet — publish what we can directly rather than + // leaving the site empty. Tracked-event recaps genuinely need the AI + // (summarizing many messages isn't something to fake), so those still wait. + const published = await runPassthroughCycle(settings); + if (published > 0) { + logger.warn('scheduler', `AI service unreachable — published ${published} article(s) directly (no rewriting/merging)`); + } + return; + } + + const published = await runSynthesisCycle(p, settings); + const recapped = await runEventRecaps(p, settings); + if (published > 0 || recapped > 0) { + logger.info('scheduler', `Synthesis tick: published ${published} article(s), ${recapped} event recap(s)`); + } + } catch (err) { + logger.error('scheduler', `Synthesis tick failed: ${(err as Error).message}`); + } + }, SYNTHESIS_TICK_MS); + + setInterval(() => { + try { + runRetentionSweep(settingsDb.getSettings()); + pruneExpiredSessions(); + logger.info('retention', 'Retention sweep completed'); + } catch (err) { + logger.error('retention', `Retention tick failed: ${(err as Error).message}`); + } + }, RETENTION_TICK_MS); + + logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h'); +} diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts new file mode 100644 index 0000000..c2318c8 --- /dev/null +++ b/backend/src/storage/db/articles.ts @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { MergedArticle } from './types.js'; + +function rowToArticle(row: any): MergedArticle { + return { + id: row.id, + title: row.title, + body: row.body, + heroImage: row.hero_image ? JSON.parse(row.hero_image) : null, + video: row.video ? JSON.parse(row.video) : null, + category: JSON.parse(row.category), + geo: row.geo, + eventId: row.event_id, + sourceCount: row.source_count, + sources: JSON.parse(row.sources), + publishedAt: row.published_at, + updatedAt: row.updated_at, + mergeConfidence: row.merge_confidence, + tags: JSON.parse(row.tags), + threadId: row.thread_id, + previousArticleId: row.previous_article_id, + nextArticleId: row.next_article_id + }; +} + +export function insertArticle(article: Omit): MergedArticle { + const id = `art-${randomUUID()}`; + db.prepare( + `INSERT INTO merged_articles + (id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + article.title, + article.body, + article.heroImage ? JSON.stringify(article.heroImage) : null, + article.video ? JSON.stringify(article.video) : null, + JSON.stringify(article.category), + article.geo, + article.eventId, + article.sourceCount, + JSON.stringify(article.sources), + article.publishedAt, + article.updatedAt, + article.mergeConfidence, + JSON.stringify(article.tags), + article.threadId, + article.previousArticleId, + article.nextArticleId + ); + if (article.previousArticleId) { + db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId); + } + return { ...article, id }; +} + +export function getArticle(id: string): MergedArticle | null { + const row = db.prepare('SELECT * FROM merged_articles WHERE id = ?').get(id); + return row ? rowToArticle(row) : null; +} + +export function queryFeed(filters: { category?: string; geo?: string; eventId?: string; tag?: string }): MergedArticle[] { + let sql = 'SELECT * FROM merged_articles WHERE 1=1'; + const params: unknown[] = []; + if (filters.category) { + sql += ' AND category LIKE ?'; + params.push(`%"${filters.category}"%`); + } + if (filters.geo) { + sql += ' AND geo = ?'; + params.push(filters.geo); + } + if (filters.eventId) { + sql += ' AND event_id = ?'; + params.push(filters.eventId); + } + if (filters.tag) { + sql += ' AND tags LIKE ?'; + params.push(`%"${filters.tag}"%`); + } + sql += ' ORDER BY published_at DESC'; + const rows = db.prepare(sql).all(...(params as any[])); + return rows.map(rowToArticle); +} + +export function latestArticleInThread(threadId: string): MergedArticle | null { + const row = db + .prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1') + .get(threadId); + return row ? rowToArticle(row) : null; +} + +export function articlesOlderThan(days: number): MergedArticle[] { + const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); + const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff); + return rows.map(rowToArticle); +} + +export function findRecentArticleByTags(tagIds: string[], sinceDays: number): MergedArticle | null { + if (tagIds.length === 0) return null; + const cutoff = new Date(Date.now() - sinceDays * 86_400_000).toISOString(); + const rows = db + .prepare('SELECT * FROM merged_articles WHERE published_at > ? ORDER BY published_at DESC') + .all(cutoff); + for (const row of rows) { + const article = rowToArticle(row); + if (article.tags.some((t) => tagIds.includes(t))) return article; + } + return null; +} + +export function deleteArticle(id: string) { + db.prepare('DELETE FROM merged_articles WHERE id = ?').run(id); +} diff --git a/backend/src/storage/db/auth.ts b/backend/src/storage/db/auth.ts new file mode 100644 index 0000000..cbaa42b --- /dev/null +++ b/backend/src/storage/db/auth.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import { hashPassword } from '../../api/password.js'; +import { logger } from './logs.js'; + +const SESSION_TTL_HOURS = 24; + +export function ensureAdminUserSeeded(defaultUsername: string, defaultPassword: string) { + const existing = db.prepare('SELECT id FROM admin_users LIMIT 1').get(); + if (existing) return; + db.prepare('INSERT INTO admin_users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)').run( + randomUUID(), + defaultUsername, + hashPassword(defaultPassword), + new Date().toISOString() + ); + logger.warn('auth', `Seeded initial admin user "${defaultUsername}". Change this password after first login.`); +} + +export function getAdminUserByUsername(username: string) { + return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as + | { id: string; username: string; password_hash: string } + | undefined; +} + +export function createSession(ip: string | null): { id: string; expiresAt: string } { + const id = randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_TTL_HOURS * 3600_000).toISOString(); + db.prepare('INSERT INTO sessions (id, created_at, expires_at, ip) VALUES (?, ?, ?, ?)').run( + id, + now.toISOString(), + expiresAt, + ip + ); + return { id, expiresAt }; +} + +export function isSessionValid(id: string): boolean { + const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) as + | { expires_at: string } + | undefined; + if (!row) return false; + return new Date(row.expires_at).getTime() > Date.now(); +} + +export function deleteSession(id: string) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(id); +} + +export function pruneExpiredSessions() { + db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(new Date().toISOString()); +} diff --git a/backend/src/storage/db/categories.ts b/backend/src/storage/db/categories.ts new file mode 100644 index 0000000..7059759 --- /dev/null +++ b/backend/src/storage/db/categories.ts @@ -0,0 +1,16 @@ +import { db } from './index.js'; +import type { Category } from './types.js'; + +function rowToCategory(row: any): Category { + return { id: row.id, name: row.name, priorityRank: row.priority_rank, isDefault: !!row.is_default }; +} + +export function listCategories(): Category[] { + const rows = db.prepare('SELECT * FROM categories ORDER BY priority_rank').all(); + return rows.map(rowToCategory); +} + +export function setCategoryOrder(order: { id: string; priorityRank: number }[]) { + const stmt = db.prepare('UPDATE categories SET priority_rank = ? WHERE id = ?'); + for (const c of order) stmt.run(c.priorityRank, c.id); +} diff --git a/backend/src/storage/db/contentItems.ts b/backend/src/storage/db/contentItems.ts new file mode 100644 index 0000000..2d0e05e --- /dev/null +++ b/backend/src/storage/db/contentItems.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { ContentItem } from './types.js'; + +function rowToItem(row: any): ContentItem { + return { + id: row.id, + sourceId: row.source_id, + type: row.type, + title: row.title, + summary: row.summary, + body: row.body, + images: JSON.parse(row.images), + videos: JSON.parse(row.videos), + link: row.link, + publishedAt: row.published_at, + fetchedAt: row.fetched_at, + tags: JSON.parse(row.tags), + geo: row.geo, + embedding: row.embedding ? JSON.parse(row.embedding) : null, + eventId: row.event_id, + clusterId: row.cluster_id, + raw: row.raw ? JSON.parse(row.raw) : null + }; +} + +export function insertContentItem(item: Omit): ContentItem { + const id = `ci-${randomUUID()}`; + db.prepare( + `INSERT INTO content_items + (id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, raw) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + item.sourceId, + item.type, + item.title, + item.summary, + item.body, + JSON.stringify(item.images), + JSON.stringify(item.videos), + item.link, + item.publishedAt, + item.fetchedAt, + JSON.stringify(item.tags), + item.geo, + item.embedding ? JSON.stringify(item.embedding) : null, + item.eventId, + item.clusterId, + item.raw ? JSON.stringify(item.raw) : null + ); + return { ...item, id }; +} + +/** Avoids re-ingesting the same story twice on repeated polls. */ +export function existsByLink(link: string): boolean { + const row = db.prepare('SELECT id FROM content_items WHERE link = ?').get(link); + return !!row; +} + +export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): ContentItem[] { + const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id IS NULL').all(); + const items = rows.map(rowToItem); + if (excludeSourceIds.length === 0) return items; + return items.filter((i) => !excludeSourceIds.includes(i.sourceId)); +} + +export function unclusteredItemsForSources(sourceIds: string[], sinceISO: string): ContentItem[] { + if (sourceIds.length === 0) return []; + const placeholders = sourceIds.map(() => '?').join(','); + const rows = db + .prepare(`SELECT * FROM content_items WHERE cluster_id IS NULL AND source_id IN (${placeholders}) AND fetched_at > ?`) + .all(...sourceIds, sinceISO); + return rows.map(rowToItem); +} + +export function setEmbedding(id: string, embedding: number[]) { + db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id); +} + +export function assignCluster(ids: string[], clusterId: string) { + const stmt = db.prepare('UPDATE content_items SET cluster_id = ? WHERE id = ?'); + for (const id of ids) stmt.run(clusterId, id); +} + +export function itemsByCluster(clusterId: string): ContentItem[] { + const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId); + return rows.map(rowToItem); +} + +export function itemsOlderThan(days: number): ContentItem[] { + const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); + const rows = db.prepare('SELECT * FROM content_items WHERE fetched_at < ?').all(cutoff); + return rows.map(rowToItem); +} + +export function deleteContentItems(ids: string[]) { + const stmt = db.prepare('DELETE FROM content_items WHERE id = ?'); + for (const id of ids) stmt.run(id); +} diff --git a/backend/src/storage/db/events.ts b/backend/src/storage/db/events.ts new file mode 100644 index 0000000..5225cfe --- /dev/null +++ b/backend/src/storage/db/events.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { TrackedEvent } from './types.js'; + +function rowToEvent(row: any): TrackedEvent { + return { + id: row.id, + name: row.name, + description: row.description, + sourceIds: JSON.parse(row.source_ids), + cadence: row.cadence, + cadenceTime: row.cadence_time, + active: !!row.active, + retentionOverrideDays: row.retention_override_days, + lastRecapAt: row.last_recap_at, + createdAt: row.created_at + }; +} + +export function listEvents(): TrackedEvent[] { + const rows = db.prepare('SELECT * FROM tracked_events ORDER BY created_at').all(); + return rows.map(rowToEvent); +} + +export function listActiveEvents(): TrackedEvent[] { + const rows = db.prepare('SELECT * FROM tracked_events WHERE active = 1').all(); + return rows.map(rowToEvent); +} + +export function getEvent(id: string): TrackedEvent | null { + const row = db.prepare('SELECT * FROM tracked_events WHERE id = ?').get(id); + return row ? rowToEvent(row) : null; +} + +export function createEvent(input: Partial): TrackedEvent { + const id = `evt-${randomUUID()}`; + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO tracked_events (id, name, description, source_ids, cadence, cadence_time, active, retention_override_days, last_recap_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)` + ).run( + id, + input.name ?? 'Untitled event', + input.description ?? '', + JSON.stringify(input.sourceIds ?? []), + input.cadence ?? 'continuous', + input.cadenceTime ?? null, + input.active === false ? 0 : 1, + input.retentionOverrideDays ?? null, + now + ); + return getEvent(id)!; +} + +export function updateEvent(id: string, patch: Partial): TrackedEvent | null { + const existing = getEvent(id); + if (!existing) return null; + const merged = { ...existing, ...patch }; + db.prepare( + `UPDATE tracked_events SET name=?, description=?, source_ids=?, cadence=?, cadence_time=?, active=?, retention_override_days=?, last_recap_at=? WHERE id=?` + ).run( + merged.name, + merged.description, + JSON.stringify(merged.sourceIds), + merged.cadence, + merged.cadenceTime, + merged.active ? 1 : 0, + merged.retentionOverrideDays, + merged.lastRecapAt, + id + ); + return getEvent(id); +} + +export function deleteEvent(id: string) { + db.prepare('DELETE FROM tracked_events WHERE id = ?').run(id); +} + +export function markRecapped(id: string) { + db.prepare('UPDATE tracked_events SET last_recap_at = ? WHERE id = ?').run(new Date().toISOString(), id); +} diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts new file mode 100644 index 0000000..4d700c0 --- /dev/null +++ b/backend/src/storage/db/index.ts @@ -0,0 +1,196 @@ +// Uses Node's built-in node:sqlite (experimental as of Node 22) — no native dependency +// to compile, which matters on the target hardware (i5-6600K, no GPU) as much as it +// matters for keeping the backend lightweight in general. +// +// If node:sqlite's API changes in a future Node version, `better-sqlite3` is a drop-in +// alternative with a near-identical synchronous API — see project-structure.md. + +import { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +import fs from 'node:fs'; + +const DB_PATH = process.env.DB_PATH || './data/homefeed.db'; + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + +export const db = new DatabaseSync(DB_PATH); +db.exec('PRAGMA journal_mode = WAL;'); +db.exec('PRAGMA foreign_keys = ON;'); + +export function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, -- rss | api | telegram | custom + category TEXT NOT NULL DEFAULT '[]', -- JSON array + url TEXT, + config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders + poll_interval_minutes INTEGER NOT NULL DEFAULT 15, + enabled INTEGER NOT NULL DEFAULT 1, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS content_items ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'article', + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + body TEXT, + images TEXT NOT NULL DEFAULT '[]', -- JSON + videos TEXT NOT NULL DEFAULT '[]', -- JSON + link TEXT NOT NULL, + published_at TEXT NOT NULL, + fetched_at TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', -- JSON, raw extracted tag labels pre-dedup + geo TEXT, + embedding TEXT, -- JSON float array + event_id TEXT, + cluster_id TEXT, -- set once assigned to a cluster awaiting synthesis + raw TEXT -- JSON, original payload + ); + CREATE INDEX IF NOT EXISTS idx_content_items_source ON content_items(source_id); + CREATE INDEX IF NOT EXISTS idx_content_items_cluster ON content_items(cluster_id); + CREATE INDEX IF NOT EXISTS idx_content_items_published ON content_items(published_at); + + CREATE TABLE IF NOT EXISTS merged_articles ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL, + hero_image TEXT, -- JSON {url, sourceItemId, selectionReason} + video TEXT, -- JSON + category TEXT NOT NULL DEFAULT '[]', -- JSON + geo TEXT, + event_id TEXT, + source_count INTEGER NOT NULL DEFAULT 1, + sources TEXT NOT NULL DEFAULT '[]', -- JSON, copied at publish time (see schema doc) + published_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + merge_confidence REAL NOT NULL DEFAULT 1.0, + tags TEXT NOT NULL DEFAULT '[]', -- JSON tag ids + thread_id TEXT NOT NULL, + previous_article_id TEXT, + next_article_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at); + CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id); + + CREATE TABLE IF NOT EXISTS media_assets ( + id TEXT PRIMARY KEY, + source_url TEXT NOT NULL, + local_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + downloaded_at TEXT NOT NULL, + tier TEXT NOT NULL, -- candidate | published + content_item_id TEXT, + article_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_media_tier ON media_assets(tier); + + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + aliases TEXT NOT NULL DEFAULT '[]', -- JSON + slug TEXT NOT NULL UNIQUE, + embedding TEXT NOT NULL DEFAULT '[]', -- JSON + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + article_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' -- active | expired + ); + + CREATE TABLE IF NOT EXISTS tracked_events ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + source_ids TEXT NOT NULL DEFAULT '[]', -- JSON + cadence TEXT NOT NULL DEFAULT 'continuous', + cadence_time TEXT, + active INTEGER NOT NULL DEFAULT 1, + retention_override_days INTEGER, + last_recap_at TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS local_regions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_ids TEXT NOT NULL DEFAULT '[]', -- JSON + seasonal INTEGER NOT NULL DEFAULT 0, + active_months TEXT, -- JSON int array or null + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS admin_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ip TEXT + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, -- info | warn | error + source TEXT NOT NULL, -- e.g. 'poller', 'scheduler', 'synthesis', 'retention' + message TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp); + + CREATE TABLE IF NOT EXISTS global_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row + merge_strictness INTEGER NOT NULL DEFAULT 3, + default_poll_interval_minutes INTEGER NOT NULL DEFAULT 15, + hold_before_publish_minutes INTEGER NOT NULL DEFAULT 30, + tag_dedup_threshold REAL NOT NULL DEFAULT 0.82, + tag_expiry_days INTEGER NOT NULL DEFAULT 21, + follow_up_min_hours_since_last INTEGER NOT NULL DEFAULT 6, + follow_up_min_new_sources INTEGER NOT NULL DEFAULT 2, + ai_service_host TEXT NOT NULL DEFAULT 'http://localhost', + ai_service_port INTEGER NOT NULL DEFAULT 11434, + selected_models TEXT NOT NULL DEFAULT '{}', -- JSON {embedding, image, synthesis} + published_article_max_age_days INTEGER, + raw_item_max_age_days INTEGER DEFAULT 7, + storage_cap_enabled INTEGER NOT NULL DEFAULT 1, + storage_cap_value INTEGER NOT NULL DEFAULT 500, + storage_cap_unit TEXT NOT NULL DEFAULT 'GB' + ); + `); + + // Seed the singleton settings row if it doesn't exist yet. + const existing = db.prepare('SELECT id FROM global_settings WHERE id = 1').get(); + if (!existing) { + db.prepare( + `INSERT INTO global_settings (id, selected_models) VALUES (1, '{"embedding":"nomic-embed-text","image":"","synthesis":"qwen2.5:7b-instruct-q4_K_M"}')` + ).run(); + } + + // Seed default categories if none exist yet. + const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get() as { c: number }; + if (catCount.c === 0) { + const defaults = ['Top stories', 'Local', 'World', 'Business', 'Tech', 'Culture']; + const stmt = db.prepare( + 'INSERT INTO categories (id, name, priority_rank, is_default) VALUES (?, ?, ?, 1)' + ); + defaults.forEach((name, i) => { + stmt.run(`cat-${name.toLowerCase().replace(/\s+/g, '-')}`, name, i + 1); + }); + } +} diff --git a/backend/src/storage/db/logs.ts b/backend/src/storage/db/logs.ts new file mode 100644 index 0000000..6de98be --- /dev/null +++ b/backend/src/storage/db/logs.ts @@ -0,0 +1,62 @@ +import { db } from './index.js'; + +export type LogLevel = 'info' | 'warn' | 'error'; + +export interface LogEntry { + id: number; + timestamp: string; + level: LogLevel; + source: string; + message: string; +} + +const MAX_LOGS = 2000; +let sinceLastPrune = 0; + +function insertLog(level: LogLevel, source: string, message: string) { + db.prepare('INSERT INTO logs (timestamp, level, source, message) VALUES (?, ?, ?, ?)').run( + new Date().toISOString(), + level, + source, + message + ); + + // Cheap periodic prune rather than checking row count on every insert. + sinceLastPrune++; + if (sinceLastPrune >= 100) { + sinceLastPrune = 0; + db.prepare( + 'DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?)' + ).run(MAX_LOGS); + } +} + +/** Writes to both the logs table (for the admin panel) and the console (for the terminal). */ +export const logger = { + info(source: string, message: string) { + console.log(`[${source}] ${message}`); + insertLog('info', source, message); + }, + warn(source: string, message: string) { + console.warn(`[${source}] ${message}`); + insertLog('warn', source, message); + }, + error(source: string, message: string) { + console.error(`[${source}] ${message}`); + insertLog('error', source, message); + } +}; + +export function listLogs(filters: { level?: LogLevel; limit?: number } = {}): LogEntry[] { + const limit = Math.min(filters.limit ?? 200, 1000); + let sql = 'SELECT * FROM logs'; + const params: unknown[] = []; + if (filters.level) { + sql += ' WHERE level = ?'; + params.push(filters.level); + } + sql += ' ORDER BY id DESC LIMIT ?'; + params.push(limit); + const rows = db.prepare(sql).all(...(params as any[])) as any[]; + return rows.map((r) => ({ id: r.id, timestamp: r.timestamp, level: r.level, source: r.source, message: r.message })); +} diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts new file mode 100644 index 0000000..35a97f5 --- /dev/null +++ b/backend/src/storage/db/settings.ts @@ -0,0 +1,65 @@ +import { db } from './index.js'; +import type { GlobalSettings } from './types.js'; + +function rowToSettings(row: any): GlobalSettings { + return { + mergeStrictness: row.merge_strictness, + defaultPollIntervalMinutes: row.default_poll_interval_minutes, + holdBeforePublishMinutes: row.hold_before_publish_minutes, + tagDedupThreshold: row.tag_dedup_threshold, + tagExpiryDays: row.tag_expiry_days, + followUpMinHoursSinceLast: row.follow_up_min_hours_since_last, + followUpMinNewSources: row.follow_up_min_new_sources, + aiServiceHost: row.ai_service_host, + aiServicePort: row.ai_service_port, + selectedModels: JSON.parse(row.selected_models), + retention: { + publishedArticleMaxAgeDays: row.published_article_max_age_days, + rawItemMaxAgeDays: row.raw_item_max_age_days, + storageCapEnabled: !!row.storage_cap_enabled, + storageCapValue: row.storage_cap_value, + storageCapUnit: row.storage_cap_unit + } + }; +} + +export function getSettings(): GlobalSettings { + const row = db.prepare('SELECT * FROM global_settings WHERE id = 1').get(); + return rowToSettings(row); +} + +export function updateSettings(patch: Partial): GlobalSettings { + const current = getSettings(); + const merged: GlobalSettings = { + ...current, + ...patch, + retention: { ...current.retention, ...(patch.retention ?? {}) }, + selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) } + }; + db.prepare( + `UPDATE global_settings SET + merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?, + tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?, + ai_service_host=?, ai_service_port=?, selected_models=?, + published_article_max_age_days=?, raw_item_max_age_days=?, + storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=? + WHERE id = 1` + ).run( + merged.mergeStrictness, + merged.defaultPollIntervalMinutes, + merged.holdBeforePublishMinutes, + merged.tagDedupThreshold, + merged.tagExpiryDays, + merged.followUpMinHoursSinceLast, + merged.followUpMinNewSources, + merged.aiServiceHost, + merged.aiServicePort, + JSON.stringify(merged.selectedModels), + merged.retention.publishedArticleMaxAgeDays, + merged.retention.rawItemMaxAgeDays, + merged.retention.storageCapEnabled ? 1 : 0, + merged.retention.storageCapValue, + merged.retention.storageCapUnit + ); + return getSettings(); +} diff --git a/backend/src/storage/db/sources.ts b/backend/src/storage/db/sources.ts new file mode 100644 index 0000000..e050428 --- /dev/null +++ b/backend/src/storage/db/sources.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { Source } from './types.js'; + +function rowToSource(row: any): Source { + return { + id: row.id, + name: row.name, + type: row.type, + category: JSON.parse(row.category), + url: row.url, + config: JSON.parse(row.config), + pollIntervalMinutes: row.poll_interval_minutes, + enabled: !!row.enabled, + lastPolledAt: row.last_polled_at, + lastError: row.last_error, + createdAt: row.created_at + }; +} + +export function listSources(): Source[] { + const rows = db.prepare('SELECT * FROM sources ORDER BY created_at').all(); + return rows.map(rowToSource); +} + +export function listEnabledSources(): Source[] { + const rows = db.prepare('SELECT * FROM sources WHERE enabled = 1').all(); + return rows.map(rowToSource); +} + +export function getSource(id: string): Source | null { + const row = db.prepare('SELECT * FROM sources WHERE id = ?').get(id); + return row ? rowToSource(row) : null; +} + +export function createSource(input: Partial): Source { + const id = `src-${randomUUID()}`; + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO sources (id, name, type, category, url, config, poll_interval_minutes, enabled, last_polled_at, last_error, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)` + ).run( + id, + input.name ?? 'Untitled source', + input.type ?? 'rss', + JSON.stringify(input.category ?? []), + input.url ?? null, + JSON.stringify(input.config ?? {}), + input.pollIntervalMinutes ?? 15, + input.enabled === false ? 0 : 1, + now + ); + return getSource(id)!; +} + +export function updateSource(id: string, patch: Partial): Source | null { + const existing = getSource(id); + if (!existing) return null; + const merged = { ...existing, ...patch }; + db.prepare( + `UPDATE sources SET name=?, type=?, category=?, url=?, config=?, poll_interval_minutes=?, enabled=?, last_polled_at=?, last_error=? WHERE id=?` + ).run( + merged.name, + merged.type, + JSON.stringify(merged.category), + merged.url, + JSON.stringify(merged.config), + merged.pollIntervalMinutes, + merged.enabled ? 1 : 0, + merged.lastPolledAt, + merged.lastError, + id + ); + return getSource(id); +} + +export function deleteSource(id: string): void { + db.prepare('DELETE FROM sources WHERE id = ?').run(id); +} + +export function markPolled(id: string, error: string | null) { + db.prepare('UPDATE sources SET last_polled_at = ?, last_error = ? WHERE id = ?').run( + new Date().toISOString(), + error, + id + ); +} + +/** Sources due for polling right now, based on their own interval (or the global default). */ +export function sourcesDueForPoll(defaultIntervalMinutes: number): Source[] { + const now = Date.now(); + return listEnabledSources().filter((s) => { + if (!s.lastPolledAt) return true; + const interval = (s.pollIntervalMinutes || defaultIntervalMinutes) * 60_000; + return now - new Date(s.lastPolledAt).getTime() >= interval; + }); +} diff --git a/backend/src/storage/db/tags.ts b/backend/src/storage/db/tags.ts new file mode 100644 index 0000000..e2ea9f6 --- /dev/null +++ b/backend/src/storage/db/tags.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { Tag } from './types.js'; + +function rowToTag(row: any): Tag { + return { + id: row.id, + label: row.label, + aliases: JSON.parse(row.aliases), + slug: row.slug, + embedding: JSON.parse(row.embedding), + firstSeenAt: row.first_seen_at, + lastSeenAt: row.last_seen_at, + articleCount: row.article_count, + status: row.status + }; +} + +function slugify(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + +export function listActiveTags(): Tag[] { + const rows = db.prepare("SELECT * FROM tags WHERE status = 'active'").all(); + return rows.map(rowToTag); +} + +export function getTagsByIds(ids: string[]): Tag[] { + if (ids.length === 0) return []; + const placeholders = ids.map(() => '?').join(','); + const rows = db.prepare(`SELECT * FROM tags WHERE id IN (${placeholders})`).all(...ids); + return rows.map(rowToTag); +} + +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0; + let dot = 0, + normA = 0, + normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] ** 2; + normB += b[i] ** 2; + } + if (normA === 0 || normB === 0) return 0; + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +/** + * Resolves a candidate tag label to an existing tag (adding it as an alias) or creates + * a new one, based on embedding similarity against active tags. See "Tag" and its + * dedup flow in homefeed-data-schema.md. + */ +export function resolveOrCreateTag(label: string, embedding: number[], dedupThreshold: number): Tag { + const active = listActiveTags(); + let best: { tag: Tag; score: number } | null = null; + for (const tag of active) { + const score = cosineSimilarity(embedding, tag.embedding); + if (!best || score > best.score) best = { tag, score }; + } + + const now = new Date().toISOString(); + + if (best && best.score >= dedupThreshold) { + const tag = best.tag; + const aliases = tag.aliases.includes(label) || tag.label === label ? tag.aliases : [...tag.aliases, label]; + db.prepare('UPDATE tags SET aliases = ?, last_seen_at = ?, article_count = article_count + 1 WHERE id = ?').run( + JSON.stringify(aliases), + now, + tag.id + ); + return { ...tag, aliases, lastSeenAt: now, articleCount: tag.articleCount + 1 }; + } + + const id = `tag-${randomUUID()}`; + const slug = slugify(label); + db.prepare( + `INSERT INTO tags (id, label, aliases, slug, embedding, first_seen_at, last_seen_at, article_count, status) + VALUES (?, ?, '[]', ?, ?, ?, ?, 1, 'active')` + ).run(id, label, slug, JSON.stringify(embedding), now, now); + + return { id, label, aliases: [], slug, embedding, firstSeenAt: now, lastSeenAt: now, articleCount: 1, status: 'active' }; +} + +/** Scheduled sweep: flips tags with no new articles in `expiryDays` to expired. */ +export function expireStaleTags(expiryDays: number): number { + const cutoff = new Date(Date.now() - expiryDays * 86_400_000).toISOString(); + const result = db + .prepare("UPDATE tags SET status = 'expired' WHERE status = 'active' AND last_seen_at < ?") + .run(cutoff); + return Number(result.changes); +} diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts new file mode 100644 index 0000000..cffd27d --- /dev/null +++ b/backend/src/storage/db/types.ts @@ -0,0 +1,121 @@ +export interface Source { + id: string; + name: string; + type: 'rss' | 'api' | 'telegram' | 'custom'; + category: string[]; + url: string | null; + config: Record; + pollIntervalMinutes: number; + enabled: boolean; + lastPolledAt: string | null; + lastError: string | null; + createdAt: string; +} + +export interface ContentItem { + id: string; + sourceId: string; + type: 'article' | 'social_post' | 'alert' | 'other'; + title: string; + summary: string; + body: string | null; + images: { url: string; caption?: string; width?: number; height?: number }[]; + videos: { url: string; provider?: string; embedHtml?: string }[]; + link: string; + publishedAt: string; + fetchedAt: string; + tags: string[]; + geo: string | null; + embedding: number[] | null; + eventId: string | null; + clusterId: string | null; + raw: unknown; +} + +export interface ArticleSource { + itemId: string; + sourceName: string; + link: string; + publishedAt: string; +} + +export interface MergedArticle { + id: string; + title: string; + body: string; + heroImage: { url: string; sourceItemId: string; selectionReason: string } | null; + video: { url: string; provider?: string; sourceItemId: string } | null; + category: string[]; + geo: string | null; + eventId: string | null; + sourceCount: number; + sources: ArticleSource[]; + publishedAt: string; + updatedAt: string; + mergeConfidence: number; + tags: string[]; + threadId: string; + previousArticleId: string | null; + nextArticleId: string | null; +} + +export interface Tag { + id: string; + label: string; + aliases: string[]; + slug: string; + embedding: number[]; + firstSeenAt: string; + lastSeenAt: string; + articleCount: number; + status: 'active' | 'expired'; +} + +export interface TrackedEvent { + id: string; + name: string; + description: string; + sourceIds: string[]; + cadence: 'continuous' | 'daily' | 'hourly' | 'custom'; + cadenceTime: string | null; + active: boolean; + retentionOverrideDays: number | null; + lastRecapAt: string | null; + createdAt: string; +} + +export interface LocalRegion { + id: string; + name: string; + sourceIds: string[]; + seasonal: boolean; + activeMonths: number[] | null; + createdAt: string; +} + +export interface Category { + id: string; + name: string; + priorityRank: number; + isDefault: boolean; +} + +export interface GlobalSettings { + mergeStrictness: 1 | 2 | 3 | 4 | 5; + defaultPollIntervalMinutes: number; + holdBeforePublishMinutes: number; + tagDedupThreshold: number; + tagExpiryDays: number; + followUpMinHoursSinceLast: number; + followUpMinNewSources: number; + aiServiceHost: string; + aiServicePort: number; + selectedModels: { embedding: string; image: string; synthesis: string }; + retention: { + publishedArticleMaxAgeDays: number | null; + rawItemMaxAgeDays: number | null; + storageCapEnabled: boolean; + storageCapValue: number; + storageCapUnit: 'MB' | 'GB'; + }; +} diff --git a/backend/src/storage/media/index.ts b/backend/src/storage/media/index.ts new file mode 100644 index 0000000..f0c588c --- /dev/null +++ b/backend/src/storage/media/index.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { db } from '../db/index.js'; +import { logger } from '../db/logs.js'; + +const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; +fs.mkdirSync(MEDIA_DIR, { recursive: true }); + +export interface StoredMedia { + id: string; + localPath: string; + servedPath: string; // what the API returns to the frontend + sizeBytes: number; +} + +/** + * Downloads and locally hosts an image (or other media) rather than hotlinking the + * original — see the retention design doc. `tier` is 'candidate' for raw source + * images (pruned with raw-item retention) or 'published' for an article's chosen + * image (kept for the article's own retention lifetime, independent of the source). + */ +export async function downloadAndStore( + sourceUrl: string, + tier: 'candidate' | 'published', + refs: { contentItemId?: string; articleId?: string } +): Promise { + try { + const res = await fetch(sourceUrl, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) return null; + const buffer = Buffer.from(await res.arrayBuffer()); + + const ext = guessExtension(res.headers.get('content-type') ?? '', sourceUrl); + const id = randomUUID(); + const filename = `${id}${ext}`; + const localPath = path.join(MEDIA_DIR, filename); + fs.writeFileSync(localPath, buffer); + + db.prepare( + `INSERT INTO media_assets (id, source_url, local_path, size_bytes, downloaded_at, tier, content_item_id, article_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, sourceUrl, localPath, buffer.length, new Date().toISOString(), tier, refs.contentItemId ?? null, refs.articleId ?? null); + + return { id, localPath, servedPath: `/media/${filename}`, sizeBytes: buffer.length }; + } catch (err) { + logger.error('media', `Failed to download ${sourceUrl}: ${(err as Error).message}`); + return null; + } +} + +/** Promotes a candidate-tier asset to published-tier so raw-item retention can no longer prune it. */ +export function promoteToPublished(mediaId: string, articleId: string) { + db.prepare("UPDATE media_assets SET tier = 'published', article_id = ? WHERE id = ?").run(articleId, mediaId); +} + +export function totalStorageBytes(): number { + const row = db.prepare('SELECT COALESCE(SUM(size_bytes), 0) as total FROM media_assets').get() as { total: number }; + return row.total; +} + +export function deleteCandidateMediaOlderThan(days: number): number { + const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); + const rows = db + .prepare("SELECT id, local_path FROM media_assets WHERE tier = 'candidate' AND downloaded_at < ?") + .all(cutoff) as { id: string; local_path: string }[]; + for (const row of rows) { + fs.rm(row.local_path, () => {}); + } + db.prepare("DELETE FROM media_assets WHERE tier = 'candidate' AND downloaded_at < ?").run(cutoff); + return rows.length; +} + +function guessExtension(contentType: string, url: string): string { + if (contentType.includes('jpeg')) return '.jpg'; + if (contentType.includes('png')) return '.png'; + if (contentType.includes('webp')) return '.webp'; + if (contentType.includes('gif')) return '.gif'; + const match = url.match(/\.(jpg|jpeg|png|webp|gif)(\?|$)/i); + return match ? `.${match[1].toLowerCase()}` : '.jpg'; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..29ac881 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..c959413 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.2 create --template minimal --types ts --install npm frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7408bbe --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1390 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", + "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.2.tgz", + "integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bd0f349 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..05902d4 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,24 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts new file mode 100644 index 0000000..35103f7 --- /dev/null +++ b/frontend/src/lib/adminApi.ts @@ -0,0 +1,92 @@ +import { getBackendUrl } from './config'; +import type { + AdminSettings, + AdminSource, + AdminTrackedEvent, + ModelCatalog, + AiStatus, + LogEntry +} from './adminTypes'; + +async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { + const res = await fetchFn(`${getBackendUrl()}${path}`, { + ...options, + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } + }); + if (res.status === 401) { + const err = new Error('unauthorized') as Error & { status?: number }; + err.status = 401; + throw err; + } + if (!res.ok) throw new Error(`Admin request failed: ${path} (${res.status})`); + if (res.status === 204) return undefined as T; + return res.json(); +} + +// Auth +export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise { + const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Login failed (${res.status})`); + } +} + +export async function logout(fetchFn: typeof fetch = fetch): Promise { + await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' }); +} + +// Settings +export const getSettings = (fetchFn?: typeof fetch) => + request('/api/admin/settings', {}, fetchFn); + +export const updateSettings = (patch: Partial, fetchFn?: typeof fetch) => + request('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + +// Sources +export const getSources = (fetchFn?: typeof fetch) => + request('/api/admin/sources', {}, fetchFn); + +export const addSource = (source: Partial, fetchFn?: typeof fetch) => + request('/api/admin/sources', { method: 'POST', body: JSON.stringify(source) }, fetchFn); + +export const updateSource = (id: string, patch: Partial, fetchFn?: typeof fetch) => + request(`/api/admin/sources/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + +export const deleteSource = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/sources/${id}`, { method: 'DELETE' }, fetchFn); + +export const pollSourceNow = (id: string, fetchFn?: typeof fetch) => + request<{ ingested: number; source: AdminSource }>(`/api/admin/sources/${id}/poll`, { method: 'POST' }, fetchFn); + +// Tracked events +export const getEvents = (fetchFn?: typeof fetch) => + request('/api/admin/events', {}, fetchFn); + +export const addEvent = (event: Partial, fetchFn?: typeof fetch) => + request('/api/admin/events', { method: 'POST', body: JSON.stringify(event) }, fetchFn); + +export const updateEvent = (id: string, patch: Partial, fetchFn?: typeof fetch) => + request(`/api/admin/events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + +export const deleteEvent = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/events/${id}`, { method: 'DELETE' }, fetchFn); + +// Models / AI service +export const getModels = (fetchFn?: typeof fetch) => + request('/api/admin/models', {}, fetchFn); + +export const getAiStatus = (fetchFn?: typeof fetch) => + request('/api/admin/ai-status', {}, fetchFn); + +// Logs +export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: number } = {}, fetchFn?: typeof fetch) => { + const qs = new URLSearchParams(filters as Record).toString(); + return request(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn); +}; diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts new file mode 100644 index 0000000..520664c --- /dev/null +++ b/frontend/src/lib/adminTypes.ts @@ -0,0 +1,74 @@ +export interface RetentionSettings { + publishedArticleMaxAgeDays: number | null; + rawItemMaxAgeDays: number | null; + storageCapEnabled: boolean; + storageCapValue: number; + storageCapUnit: 'MB' | 'GB'; + storageUsedMB: number; +} + +export interface CategoryPriority { + id: string; + name: string; + priorityRank: number; +} + +export interface AdminSettings { + mergeStrictness: 1 | 2 | 3 | 4 | 5; + defaultPollIntervalMinutes: number; + holdBeforePublishMinutes: number; + tagDedupThreshold: number; + tagExpiryDays: number; + followUpMinHoursSinceLast: number; + followUpMinNewSources: number; + aiServiceHost: string; + aiServicePort: number; + selectedModels: { embedding: string; image: string; synthesis: string }; + retention: RetentionSettings; + categoryPriority: CategoryPriority[]; +} + +export interface AdminSource { + id: string; + name: string; + type: 'rss' | 'api' | 'telegram' | 'custom'; + category: string[]; + url: string; + pollIntervalMinutes: number; + enabled: boolean; + lastPolledAt: string | null; + lastError: string | null; +} + +export interface AdminTrackedEvent { + id: string; + name: string; + description: string; + sourceIds: string[]; + cadence: 'continuous' | 'daily' | 'hourly' | 'custom'; + cadenceTime: string | null; + active: boolean; + retentionOverrideDays: number | null; +} + +export interface ModelCatalog { + embedding: string[]; + image: string[]; + synthesis: string[]; +} + +export interface AiStatus { + connected: boolean; + host: string; + port: number; + ramGB: number; + gpu: string; +} + +export interface LogEntry { + id: number; + timestamp: string; + level: 'info' | 'warn' | 'error'; + source: string; + message: string; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..c9a826b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,28 @@ +import { getBackendUrl } from './config'; +import type { MergedArticle, Tag, TrackedEventPublic } from './types'; + +async function get(path: string, fetchFn: typeof fetch = fetch): Promise { + const res = await fetchFn(`${getBackendUrl()}${path}`); + if (!res.ok) throw new Error(`Request failed: ${path} (${res.status})`); + return res.json(); +} + +export function getFeed( + params: { category?: string; geo?: string; eventId?: string; tag?: string } = {}, + fetchFn?: typeof fetch +): Promise { + const qs = new URLSearchParams(params as Record).toString(); + return get(`/api/feed${qs ? `?${qs}` : ''}`, fetchFn); +} + +export function getArticle(id: string, fetchFn?: typeof fetch): Promise { + return get(`/api/article/${id}`, fetchFn); +} + +export function getTags(fetchFn?: typeof fetch): Promise { + return get('/api/tags', fetchFn); +} + +export function getEvents(fetchFn?: typeof fetch): Promise { + return get('/api/events', fetchFn); +} diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte new file mode 100644 index 0000000..009c574 --- /dev/null +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -0,0 +1,76 @@ + + + + {#if article.heroImage} + + {:else} +
+ {/if} +
+ {#if article.video} + Video + {:else if article.sourceCount > 1} + {sourceLabel} + {:else} + {sourceLabel} + {/if} +
+
{article.title}
+
{timeAgo(article.publishedAt)}
+
+ + diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..3ba7d92 --- /dev/null +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/lib/components/admin/ConnectionsTab.svelte b/frontend/src/lib/components/admin/ConnectionsTab.svelte new file mode 100644 index 0000000..d7132e7 --- /dev/null +++ b/frontend/src/lib/components/admin/ConnectionsTab.svelte @@ -0,0 +1,133 @@ + + +
+
+ Backend address + {#if backendSaved}✓ Saved to this browser{/if} +
+

+ Stored in this browser only — each device connecting to the admin panel sets its own + backend address. Not synced across devices. +

+
+ + +
+
+ +
+
+ AI service connection + +
+

+ Address of your self-hosted inference server (e.g. Ollama). This is a backend setting, + shared across everyone using this admin panel. +

+
+ + + + +
+
+ {#if aiStatus.connected} + ✓ Connected · {aiStatus.ramGB}GB RAM · GPU: {aiStatus.gpu} + {:else} + ✕ Unreachable + {/if} +
+
+ + diff --git a/frontend/src/lib/components/admin/EventsTab.svelte b/frontend/src/lib/components/admin/EventsTab.svelte new file mode 100644 index 0000000..ebcf81b --- /dev/null +++ b/frontend/src/lib/components/admin/EventsTab.svelte @@ -0,0 +1,172 @@ + + +
+ {events.length} tracked events + +
+ +{#if showAdd} +
+
+ + + {#if newEvent.cadence === 'daily'} + + {/if} +
+
+ + +
+

Assign sources to this event from the Sources tab once created.

+
+{/if} + +
+ {#each events as event (event.id)} +
+
+
{event.name}
+
+ {sourceNames(event.sourceIds)} · + {event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence} +
+
+ toggleActive(event)} role="button" tabindex="0"> + {event.active ? 'Active' : 'Paused'} + + +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/admin/LogsTab.svelte b/frontend/src/lib/components/admin/LogsTab.svelte new file mode 100644 index 0000000..8406d9e --- /dev/null +++ b/frontend/src/lib/components/admin/LogsTab.svelte @@ -0,0 +1,169 @@ + + +
+
+ + + + +
+
+ + +
+
+ +
+ {#if logs.length === 0} +
Nothing logged yet.
+ {/if} + {#each logs as log (log.id)} +
+ {formatTime(log.timestamp)} + + {log.level === 'info' ? 'standard' : log.level} + + {log.source} + {log.message} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte new file mode 100644 index 0000000..40b2c4f --- /dev/null +++ b/frontend/src/lib/components/admin/MergeTab.svelte @@ -0,0 +1,259 @@ + + +
+
+ Merge strictness + +
+

How similar articles must be before they're combined into one story.

+
+ Loose + + Strict + {local.mergeStrictness} +
+
+ +
+
+ Poll interval +

How often each source is checked for new items.

+ +
+
+ Hold before publish +

Wait window to gather more sources before finalizing a story.

+ +
+
+ +
+ Follow-up articles +

+ Instead of editing a published article, a distinct follow-up is created once enough new + corroborating sources arrive after enough time has passed. +

+
+
+ + +
+
+ + +
+
+
+ +
+ Category priority +

+ Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower + categories just wait longer when the queue is busy. +

+
+ {#each local.categoryPriority as cat, i (cat.id)} +
+ {i + 1} + {cat.name} + + +
+ {/each} +
+
+ +
+ Tags +
+
+ +
+ + {local.tagDedupThreshold.toFixed(2)} +
+
+
+ + +
+
+
+ + diff --git a/frontend/src/lib/components/admin/ModelsTab.svelte b/frontend/src/lib/components/admin/ModelsTab.svelte new file mode 100644 index 0000000..f40851a --- /dev/null +++ b/frontend/src/lib/components/admin/ModelsTab.svelte @@ -0,0 +1,127 @@ + + +
+
+ AI service connection +
+

+ Address of your self-hosted inference server (e.g. Ollama). Model lists below are fetched + live from it. +

+
+ {#if aiStatus.connected} + ✓ Connected · {aiStatus.host}:{aiStatus.port} · {aiStatus.ramGB}GB RAM · GPU: {aiStatus.gpu} + {:else} + ✕ Unreachable + {/if} + +
+
+ +
+
+ Embedding & clustering +
+ +
+ +
+
+ Image selection +
+ +
+ +
+
+ Article synthesis + +
+ +
+ + diff --git a/frontend/src/lib/components/admin/RetentionTab.svelte b/frontend/src/lib/components/admin/RetentionTab.svelte new file mode 100644 index 0000000..08738de --- /dev/null +++ b/frontend/src/lib/components/admin/RetentionTab.svelte @@ -0,0 +1,189 @@ + + +
+
+ Published articles + +
+

How long merged, published stories stay available.

+
+ {#each presets as preset} + + {/each} +
+
+ +
+ Raw source items +

Individual RSS/API/Telegram entries used to build merged stories.

+
+ {#each [{ label: '3 days', value: 3 }, { label: '7 days', value: 7 }, { label: '30 days', value: 30 }] as preset} + + {/each} +
+
+ +
+
+ Storage cap + +
+

+ When total storage exceeds this size, oldest items are deleted first (FIFO) regardless of + the age settings above. +

+
+ + + currently using {retention.storageUsedMB} MB +
+
+
+
+
+ + diff --git a/frontend/src/lib/components/admin/SaveStatus.svelte b/frontend/src/lib/components/admin/SaveStatus.svelte new file mode 100644 index 0000000..9fc5a24 --- /dev/null +++ b/frontend/src/lib/components/admin/SaveStatus.svelte @@ -0,0 +1,26 @@ + + +{#if status === 'saving'} + Saving… +{:else if status === 'saved'} + ✓ Saved +{:else if status === 'error'} + Save failed +{/if} + + diff --git a/frontend/src/lib/components/admin/SourcesTab.svelte b/frontend/src/lib/components/admin/SourcesTab.svelte new file mode 100644 index 0000000..ba40c04 --- /dev/null +++ b/frontend/src/lib/components/admin/SourcesTab.svelte @@ -0,0 +1,246 @@ + + +
+ {sources.length} sources + +
+ +{#if showAdd} +
+
+ + + + +
+
+ + +
+
+{/if} + +
+
+ + Name + Type + Category + Poll + +
+ {#each sources as source (source.id)} +
+ +
+
{source.name}
+
+ {#if justPolled?.id === source.id} + {justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'} + {:else if source.lastError} + last poll failed · {source.lastError} + {:else} + {source.url} + {/if} +
+
+ {source.type.toUpperCase()} + {source.category.join(', ')} + {source.pollIntervalMinutes} min +
+ + +
+
+ {/each} +
+ + diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 0000000..3743ddd --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,22 @@ +// The frontend->backend address is deliberately NOT a backend-stored setting +// (see project-structure.md "Frontend → backend address"). It's read here from: +// 1. a deploy-time env var (VITE_BACKEND_URL), or +// 2. a value saved in the browser via the connection setup screen, or +// 3. a localhost fallback for local dev against the mock backend. + +const STORAGE_KEY = 'homefeed:backendUrl'; +const DEFAULT_URL = 'http://localhost:4000'; + +export function getBackendUrl(): string { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return stored; + } + return import.meta.env.VITE_BACKEND_URL || DEFAULT_URL; +} + +export function setBackendUrl(url: string) { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, url); + } +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..e2f6e83 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,9 @@ +export function timeAgo(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime(); + const mins = Math.round(diffMs / 60000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/styles/app.css b/frontend/src/lib/styles/app.css new file mode 100644 index 0000000..5532fc9 --- /dev/null +++ b/frontend/src/lib/styles/app.css @@ -0,0 +1,83 @@ +:root { + --surface-0: #faf9f6; + --surface-1: #f0eee7; + --surface-2: #ffffff; + --text-primary: #1c1b18; + --text-secondary: #55534c; + --text-muted: #8a887f; + --text-accent: #993c1d; + --bg-accent: #faece7; + --border: #ddd9cd; + --border-accent: #d85a30; + --pill-bg: #1c1b18; + --pill-text: #ffffff; + --text-success: #3a7d4f; + --text-danger: #b3402c; + --radius: 8px; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --font-voice: Georgia, 'Times New Roman', serif; + --font-mono: 'SF Mono', Menlo, Consolas, monospace; +} + +:root[data-theme='dark'] { + --surface-0: #16181d; + --surface-1: #1f232a; + --surface-2: #262b33; + --text-primary: #e7e5df; + --text-secondary: #a7a59c; + --text-muted: #75736a; + --text-accent: #e2896a; + --bg-accent: #3a2a22; + --border: #333740; + --border-accent: #b3532c; + --pill-bg: #e7e5df; + --pill-text: #16181d; + --text-success: #5fb37b; + --text-danger: #e07356; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-sans); + transition: background-color 0.15s ease, color 0.15s ease; +} + +a { + color: var(--text-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +input, +select, +textarea, +button { + font-family: var(--font-sans); + font-size: 13px; + border: 0.5px solid var(--border); + border-radius: var(--radius); + padding: 8px 10px; + background: var(--surface-2); + color: var(--text-primary); +} + +button { + cursor: pointer; +} + +.page { + max-width: 1080px; + margin: 0 auto; + padding: 0 24px 60px; +} diff --git a/frontend/src/lib/theme.svelte.ts b/frontend/src/lib/theme.svelte.ts new file mode 100644 index 0000000..b6b0ac1 --- /dev/null +++ b/frontend/src/lib/theme.svelte.ts @@ -0,0 +1,38 @@ +const STORAGE_KEY = 'homefeed:theme'; + +type Theme = 'light' | 'dark'; + +let theme = $state('light'); + +export function getTheme(): Theme { + return theme; +} + +export function initTheme() { + if (typeof localStorage === 'undefined') return; + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === 'light' || stored === 'dark') { + theme = stored; + } else { + theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + applyTheme(); +} + +export function toggleTheme() { + theme = theme === 'light' ? 'dark' : 'light'; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, theme); + } + applyTheme(); +} + +function applyTheme() { + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-theme', theme); + } +} + +export function isDark(): boolean { + return theme === 'dark'; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..a095b82 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,44 @@ +// Mirrors homefeed-data-schema.md — the frontend's view of MergedArticle etc. +// Kept minimal to only what the frontend actually renders. + +export interface ArticleSource { + itemId: string; + sourceName: string; + link: string; + publishedAt: string; +} + +export interface MergedArticle { + id: string; + title: string; + body: string; + heroImage: { url: string; sourceItemId: string; selectionReason: string } | null; + video: { url: string; provider?: string; sourceItemId: string } | null; + category: string[]; + geo: string | null; + eventId: string | null; + sourceCount: number; + sources: ArticleSource[]; + publishedAt: string; + updatedAt: string; + mergeConfidence: number; + tags: string[]; + threadId: string; + previousArticleId: string | null; + nextArticleId: string | null; +} + +export interface Tag { + id: string; + label: string; + slug: string; + articleCount: number; + status: 'active' | 'expired'; +} + +export interface TrackedEventPublic { + id: string; + name: string; + active: boolean; + cadence: string; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..2a00da8 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,119 @@ + + +
+
+
+ Homefeed + self-hosted +
+ + +
+
+ +
+ {@render children()} +
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..cc1c3e9 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,124 @@ + + +{#if data.hero} + +
+ {#if data.hero.sourceCount > 1} + ⇄ Merged from {data.hero.sourceCount} sources + {/if} + {data.hero.category.join(', ')} +
+ {#if data.hero.heroImage} + + {/if} +
{data.hero.title}
+
{timeAgo(data.hero.publishedAt)}
+
+{/if} + +
+
+ Local · Philadelphia + See all +
+
+ {#each data.local as article} + + {/each} +
+
+ +
+
+ Business + See all +
+
+ {#each data.business as article} + + {/each} +
+
+ +
+
+ Tech + See all +
+
+ {#each data.tech as article} + + {/each} +
+
+ + diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..5748e03 --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,19 @@ +import type { PageLoad } from './$types'; +import { getFeed } from '$lib/api'; + +export const load: PageLoad = async ({ fetch }) => { + const [all, local] = await Promise.all([ + getFeed({}, fetch), + getFeed({ geo: 'philadelphia' }, fetch) + ]); + + const byCategory = (name: string) => + all.filter((a) => a.category.some((c) => c.toLowerCase() === name.toLowerCase())); + + return { + hero: all[0] ?? null, + local, + business: byCategory('business'), + tech: byCategory('tech') + }; +}; diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..f6514fe --- /dev/null +++ b/frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,50 @@ + + +
+
+
+ ← Back to site + +
+ {@render children()} +
+
+ + diff --git a/frontend/src/routes/admin/+layout.ts b/frontend/src/routes/admin/+layout.ts new file mode 100644 index 0000000..a196e00 --- /dev/null +++ b/frontend/src/routes/admin/+layout.ts @@ -0,0 +1,7 @@ +// The admin section talks to a different origin (the backend) than the frontend +// itself. During SSR, the `load` function's fetch runs on the Node server, which has +// no access to the browser's cookie jar — it can't attach the session cookie to a +// cross-origin request. Disabling SSR here means all admin data fetching happens in +// the actual browser instead, where credentials: 'include' works correctly against +// whatever cookie the browser already holds from login. +export const ssr = false; diff --git a/frontend/src/routes/admin/login/+page.svelte b/frontend/src/routes/admin/login/+page.svelte new file mode 100644 index 0000000..0fdcac2 --- /dev/null +++ b/frontend/src/routes/admin/login/+page.svelte @@ -0,0 +1,79 @@ + + +
+
+ Admin login + + + + + + + + {#if error}
{error}
{/if} + + +
+
+ + diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte new file mode 100644 index 0000000..10fa93a --- /dev/null +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -0,0 +1,89 @@ + + +
+ Admin + +
+ +
+ {#if active === 'merge'} + + {:else if active === 'sources'} + + {:else if active === 'models'} + + {:else if active === 'retention'} + + {:else if active === 'events'} + + {:else if active === 'connections'} + + {:else if active === 'logs'} + + {/if} +
+ + diff --git a/frontend/src/routes/admin/settings/+page.ts b/frontend/src/routes/admin/settings/+page.ts new file mode 100644 index 0000000..d5a9f60 --- /dev/null +++ b/frontend/src/routes/admin/settings/+page.ts @@ -0,0 +1,34 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; +import { getSettings, getSources, getEvents, getModels, getAiStatus, getLogs } from '$lib/adminApi'; +import type { ModelCatalog, AiStatus } from '$lib/adminTypes'; + +const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] }; + +export const load: PageLoad = async ({ fetch }) => { + try { + const [settings, sources, events, logs] = await Promise.all([ + getSettings(fetch), + getSources(fetch), + getEvents(fetch), + getLogs({}, fetch) + ]); + + // The AI service (Ollama) may not be running yet — that shouldn't take down the + // whole settings page, just leave the Models/Connections tabs showing "unreachable". + let models: ModelCatalog = EMPTY_MODELS; + let aiStatus: AiStatus = { connected: false, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: 0, gpu: 'unknown' }; + try { + [models, aiStatus] = await Promise.all([getModels(fetch), getAiStatus(fetch)]); + } catch { + // swallow — surfaced instead via aiStatus.connected in the UI + } + + return { settings, sources, events, models, aiStatus, logs }; + } catch (err) { + if ((err as { status?: number }).status === 401) { + throw redirect(302, '/admin/login?redirectTo=/admin/settings'); + } + throw err; + } +}; diff --git a/frontend/src/routes/article/[id]/+page.svelte b/frontend/src/routes/article/[id]/+page.svelte new file mode 100644 index 0000000..0e38b87 --- /dev/null +++ b/frontend/src/routes/article/[id]/+page.svelte @@ -0,0 +1,187 @@ + + + + +
+
+ {#if a.sourceCount > 1} + ⇄ Merged from {a.sourceCount} sources + · + {/if} + {a.category.join(', ')} +
+ +

{a.title}

+ +
+ Published {timeAgo(a.publishedAt)} + {#if a.updatedAt !== a.publishedAt} + · Updated {timeAgo(a.updatedAt)} + {/if} +
+ + {#if a.heroImage} + +
+ Image via {a.sources[0]?.sourceName ?? 'source'} +
+ {/if} + + {#each a.body.split('\n\n') as paragraph} +

{paragraph}

+ {/each} + + {#if a.video} +
▶ video embed · via {a.video.provider}
+ {/if} + + {#if data.tagLabels.length} +
+ {#each data.tagLabels as tag} + {tag.label} + {/each} +
+ {/if} + + {#if data.nextArticle} + + → Newer coverage available: {data.nextArticle.title} + + {/if} + {#if data.previousArticle} + + ← Earlier coverage: {data.previousArticle.title} + + {/if} + + {#if a.sources.length} +
+ Sources: + {#each a.sources as source, i} + {source.sourceName} + {#if i < a.sources.length - 1}·{/if} + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/routes/article/[id]/+page.ts b/frontend/src/routes/article/[id]/+page.ts new file mode 100644 index 0000000..1b3d11f --- /dev/null +++ b/frontend/src/routes/article/[id]/+page.ts @@ -0,0 +1,21 @@ +import type { PageLoad } from './$types'; +import { getArticle, getTags } from '$lib/api'; + +export const load: PageLoad = async ({ params, fetch }) => { + const [article, tags] = await Promise.all([getArticle(params.id, fetch), getTags(fetch)]); + + let nextArticle = null; + let previousArticle = null; + if (article.nextArticleId) { + nextArticle = await getArticle(article.nextArticleId, fetch); + } + if (article.previousArticleId) { + previousArticle = await getArticle(article.previousArticleId, fetch); + } + + const tagLabels = article.tags + .map((id) => tags.find((t) => t.id === id)) + .filter((t): t is NonNullable => Boolean(t)); + + return { article, tagLabels, nextArticle, previousArticle }; +}; diff --git a/frontend/src/routes/category/[name]/+page.svelte b/frontend/src/routes/category/[name]/+page.svelte new file mode 100644 index 0000000..8a21557 --- /dev/null +++ b/frontend/src/routes/category/[name]/+page.svelte @@ -0,0 +1,49 @@ + + +
+ {data.name} + {data.articles.length} stories +
+ +{#if data.articles.length === 0} +

No stories in this category yet.

+{:else} +
+ {#each data.articles as article} + + {/each} +
+{/if} + + diff --git a/frontend/src/routes/category/[name]/+page.ts b/frontend/src/routes/category/[name]/+page.ts new file mode 100644 index 0000000..e3139a0 --- /dev/null +++ b/frontend/src/routes/category/[name]/+page.ts @@ -0,0 +1,12 @@ +import type { PageLoad } from './$types'; +import { getFeed } from '$lib/api'; + +export const load: PageLoad = async ({ params, fetch }) => { + const name = params.name; + const isLocal = name.toLowerCase() === 'local'; + const articles = await getFeed( + isLocal ? { geo: 'philadelphia' } : { category: name }, + fetch + ); + return { articles, name }; +}; diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..cb76b81 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import adapter from '@sveltejs/adapter-auto'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + }) + ] +}); diff --git a/mock-backend/admin-data.js b/mock-backend/admin-data.js new file mode 100644 index 0000000..b2ea318 --- /dev/null +++ b/mock-backend/admin-data.js @@ -0,0 +1,147 @@ +// In-memory admin state for the mock backend. Mirrors GlobalSettings / Source / TrackedEvent +// from homefeed-data-schema.md. Real backend replaces this with SQLite-backed storage. + +let settings = { + mergeStrictness: 3, + defaultPollIntervalMinutes: 15, + holdBeforePublishMinutes: 30, + tagDedupThreshold: 0.82, + tagExpiryDays: 21, + followUpMinHoursSinceLast: 6, + followUpMinNewSources: 2, + aiServiceHost: "http://10.0.0.14", + aiServicePort: 11434, + selectedModels: { + embedding: "nomic-embed-text-v1.5", + image: "clip-vit-b32", + synthesis: "qwen2.5:7b-instruct-q4_K_M" + }, + retention: { + publishedArticleMaxAgeDays: 7, + rawItemMaxAgeDays: 7, + storageCapEnabled: true, + storageCapValue: 500, + storageCapUnit: "GB", + storageUsedMB: 214 + }, + categoryPriority: [ + { id: "cat-top", name: "Top stories", priorityRank: 1 }, + { id: "cat-local", name: "Local", priorityRank: 2 }, + { id: "cat-world", name: "World", priorityRank: 3 }, + { id: "cat-business", name: "Business", priorityRank: 4 }, + { id: "cat-tech", name: "Tech", priorityRank: 5 }, + { id: "cat-culture", name: "Culture", priorityRank: 6 } + ] +}; + +let sources = [ + { + id: "src-1", + name: "Reuters World", + type: "rss", + category: ["World"], + url: "https://reuters.com/world/rss", + pollIntervalMinutes: 15, + enabled: true, + lastPolledAt: new Date(Date.now() - 6 * 60000).toISOString(), + lastError: null + }, + { + id: "src-2", + name: "Middle East Watch", + type: "telegram", + category: ["Iran war (event)"], + url: "@meast_watch", + pollIntervalMinutes: 5, + enabled: true, + lastPolledAt: new Date(Date.now() - 2 * 60000).toISOString(), + lastError: null + }, + { + id: "src-3", + name: "6ABC Philadelphia", + type: "rss", + category: ["Local: Philadelphia"], + url: "https://6abc.com/feed", + pollIntervalMinutes: 10, + enabled: true, + lastPolledAt: new Date(Date.now() - 4 * 60000).toISOString(), + lastError: null + }, + { + id: "src-4", + name: "PennDOT Alerts", + type: "rss", + category: ["Local: Philadelphia"], + url: "https://penndot.pa.gov/alerts/feed", + pollIntervalMinutes: 10, + enabled: true, + lastPolledAt: new Date(Date.now() - 40 * 60000).toISOString(), + lastError: "502 Bad Gateway" + } +]; + +let events = [ + { + id: "evt-iran", + name: "Iran war", + description: "Ongoing conflict coverage, sourced primarily from Telegram channels for speed.", + sourceIds: ["src-2"], + cadence: "daily", + cadenceTime: "18:00", + active: true, + retentionOverrideDays: null + }, + { + id: "evt-fed", + name: "Fed rate decisions", + description: "", + sourceIds: ["src-1"], + cadence: "continuous", + cadenceTime: null, + active: true, + retentionOverrideDays: 7 + } +]; + +// Simulated Ollama-backed model catalog, per task +const models = { + embedding: ["nomic-embed-text-v1.5", "all-MiniLM-L6-v2"], + image: ["clip-vit-b32", "siglip-base"], + synthesis: ["qwen2.5:7b-instruct-q4_K_M", "phi3.5:3.8b-mini-instruct-q4", "llama3.1:8b-instruct-q4"] +}; + +module.exports = { + getSettings: () => settings, + updateSettings: (patch) => { + settings = { ...settings, ...patch, retention: { ...settings.retention, ...(patch.retention || {}) } }; + return settings; + }, + getSources: () => sources, + addSource: (source) => { + const newSource = { id: `src-${Date.now()}`, lastPolledAt: null, lastError: null, enabled: true, ...source }; + sources = [...sources, newSource]; + return newSource; + }, + updateSource: (id, patch) => { + sources = sources.map((s) => (s.id === id ? { ...s, ...patch } : s)); + return sources.find((s) => s.id === id); + }, + deleteSource: (id) => { + sources = sources.filter((s) => s.id !== id); + }, + getEvents: () => events, + addEvent: (event) => { + const newEvent = { id: `evt-${Date.now()}`, active: true, sourceIds: [], ...event }; + events = [...events, newEvent]; + return newEvent; + }, + updateEvent: (id, patch) => { + events = events.map((e) => (e.id === id ? { ...e, ...patch } : e)); + return events.find((e) => e.id === id); + }, + deleteEvent: (id) => { + events = events.filter((e) => e.id !== id); + }, + getModels: () => models +}; diff --git a/mock-backend/data.js b/mock-backend/data.js new file mode 100644 index 0000000..544c0ae --- /dev/null +++ b/mock-backend/data.js @@ -0,0 +1,224 @@ +// Dummy data matching MergedArticle / Tag / TrackedEvent shapes from homefeed-data-schema.md +// Images are placeholder URLs (picsum.photos) fetched by the browser at runtime, not by this server. + +const img = (seed, w = 900, h = 540) => `https://picsum.photos/seed/${seed}/${w}/${h}`; +const hoursAgo = (h) => new Date(Date.now() - h * 3600 * 1000).toISOString(); + +const articles = [ + { + id: "art-1", + title: "Coalition talks resume as ministers signal a shift on trade rules", + body: + "Negotiators returned to the table this week after a two-month pause in talks over cross-border trade rules. Reuters reported the earlier pause followed a dispute over proposed tariff timelines, while the Associated Press notes that both delegations have since dropped their public objections to the original framework.\n\nThe Guardian's coverage adds that a signing event is tentatively planned for next week, pending final sign-off from both finance ministries. The Washington Post, citing a senior trade official, describes the remaining disagreements as procedural rather than substantive.", + heroImage: { url: img("trade-talks"), sourceItemId: "ci-101", selectionReason: "Widest shot showing both delegations at the table" }, + video: { url: "", provider: "Reuters", sourceItemId: "ci-101" }, + category: ["World"], + geo: null, + eventId: null, + sourceCount: 4, + sources: [ + { itemId: "ci-101", sourceName: "Reuters", link: "https://example.com/reuters/1", publishedAt: hoursAgo(6) }, + { itemId: "ci-102", sourceName: "Associated Press", link: "https://example.com/ap/1", publishedAt: hoursAgo(5.5) }, + { itemId: "ci-103", sourceName: "The Guardian", link: "https://example.com/guardian/1", publishedAt: hoursAgo(5.2) }, + { itemId: "ci-104", sourceName: "Washington Post", link: "https://example.com/wapo/1", publishedAt: hoursAgo(4.5) } + ], + publishedAt: hoursAgo(6), + updatedAt: hoursAgo(3.8), + mergeConfidence: 0.91, + tags: ["tag-trade-talks", "tag-trump"], + threadId: "thread-trade-2026", + previousArticleId: null, + nextArticleId: "art-2" + }, + { + id: "art-2", + title: "Trade deal signed after final overnight session", + body: + "Ministers signed the finalized trade agreement early this morning after a session that ran past 2am, according to Reuters and Bloomberg. The deal narrows tariff exemptions but preserves the core framework both sides agreed to in principle last week.\n\nBloomberg reports markets responded positively in early trading, with export-heavy sectors seeing the largest gains.", + heroImage: { url: img("trade-signed"), sourceItemId: "ci-140", selectionReason: "Signing ceremony, clearest depiction of the event" }, + video: null, + category: ["World"], + geo: null, + eventId: null, + sourceCount: 2, + sources: [ + { itemId: "ci-140", sourceName: "Reuters", link: "https://example.com/reuters/2", publishedAt: hoursAgo(1.5) }, + { itemId: "ci-141", sourceName: "Bloomberg", link: "https://example.com/bloomberg/1", publishedAt: hoursAgo(1.2) } + ], + publishedAt: hoursAgo(1.1), + updatedAt: hoursAgo(1.1), + mergeConfidence: 0.88, + tags: ["tag-trade-talks"], + threadId: "thread-trade-2026", + previousArticleId: "art-1", + nextArticleId: null + }, + { + id: "art-3", + title: "Housing bill signed into law after months of negotiation", + body: "The measure passed with bipartisan support after several rounds of amendments. Washington Post reports the bill includes new funding for first-time buyer assistance programs.", + heroImage: { url: img("housing-bill"), sourceItemId: "ci-201", selectionReason: "Single source, original image retained" }, + video: null, + category: ["Business"], + geo: null, + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-201", sourceName: "Washington Post", link: "https://example.com/wapo/2", publishedAt: hoursAgo(9) }], + publishedAt: hoursAgo(9), + updatedAt: hoursAgo(9), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-housing", + previousArticleId: null, + nextArticleId: null + }, + { + id: "art-4", + title: "Retail earnings beat expectations across the board", + body: "Major retailers posted stronger than forecast quarterly results, with Fortune noting consumer spending held up despite earlier concerns about a slowdown.", + heroImage: { url: img("retail-earnings"), sourceItemId: "ci-210", selectionReason: "Single source" }, + video: null, + category: ["Business"], + geo: null, + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-210", sourceName: "Fortune", link: "https://example.com/fortune/1", publishedAt: hoursAgo(12) }], + publishedAt: hoursAgo(12), + updatedAt: hoursAgo(12), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-retail", + previousArticleId: null, + nextArticleId: null + }, + { + id: "art-5", + title: "Open-source model matches proprietary benchmark on reasoning tasks", + body: "Ars Technica reports the newly released open-weight model scored within a few points of leading closed models on a widely cited reasoning benchmark, while running on consumer-grade hardware.", + heroImage: { url: img("oss-model"), sourceItemId: "ci-301", selectionReason: "Single source" }, + video: null, + category: ["Tech"], + geo: null, + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-301", sourceName: "Ars Technica", link: "https://example.com/ars/1", publishedAt: hoursAgo(7.5) }], + publishedAt: hoursAgo(7.5), + updatedAt: hoursAgo(7.5), + mergeConfidence: 1.0, + tags: ["tag-ai-models"], + threadId: "thread-oss-model", + previousArticleId: null, + nextArticleId: null + }, + { + id: "art-6", + title: "Browser vendor rolls out default tracker blocking", + body: "The Verge reports the change applies to all users by default starting with this release, with an opt-out available in privacy settings.", + heroImage: { url: img("browser-privacy"), sourceItemId: "ci-310", selectionReason: "Single source" }, + video: null, + category: ["Tech"], + geo: null, + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-310", sourceName: "The Verge", link: "https://example.com/verge/1", publishedAt: hoursAgo(11) }], + publishedAt: hoursAgo(11), + updatedAt: hoursAgo(11), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-browser", + previousArticleId: null, + nextArticleId: null + }, + { + id: "local-1", + title: "City council approves new transit corridor along Broad Street", + body: "6ABC and the Inquirer both reported the council vote passed 12-3, with construction expected to begin next spring. The Inquirer's coverage adds that the project timeline could shift depending on federal funding approval.", + heroImage: { url: img("transit-corridor"), sourceItemId: "ci-401", selectionReason: "Clearest depiction of the proposed route" }, + video: null, + category: ["Local"], + geo: "philadelphia", + eventId: null, + sourceCount: 2, + sources: [ + { itemId: "ci-401", sourceName: "6ABC", link: "https://example.com/6abc/1", publishedAt: hoursAgo(10) }, + { itemId: "ci-402", sourceName: "The Inquirer", link: "https://example.com/inquirer/1", publishedAt: hoursAgo(9.3) } + ], + publishedAt: hoursAgo(10), + updatedAt: hoursAgo(9.3), + mergeConfidence: 0.82, + tags: [], + threadId: "thread-transit", + previousArticleId: null, + nextArticleId: null + }, + { + id: "local-2", + title: "School district announces revised calendar for fall term", + body: "WHYY reports the district moved the first day back by one week to accommodate facility upgrades over the summer.", + heroImage: { url: img("school-calendar"), sourceItemId: "ci-410", selectionReason: "Single source" }, + video: null, + category: ["Local"], + geo: "philadelphia", + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-410", sourceName: "WHYY", link: "https://example.com/whyy/1", publishedAt: hoursAgo(13) }], + publishedAt: hoursAgo(13), + updatedAt: hoursAgo(13), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-school", + previousArticleId: null, + nextArticleId: null + }, + { + id: "local-3", + title: "Fireworks show draws record crowd to Penn's Landing", + body: "6ABC's coverage includes video from the riverfront as the annual show drew what organizers called its largest crowd yet.", + heroImage: { url: img("fireworks"), sourceItemId: "ci-420", selectionReason: "Single source" }, + video: { url: "", provider: "6ABC", sourceItemId: "ci-420" }, + category: ["Local"], + geo: "philadelphia", + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-420", sourceName: "6ABC", link: "https://example.com/6abc/2", publishedAt: hoursAgo(19) }], + publishedAt: hoursAgo(19), + updatedAt: hoursAgo(19), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-fireworks", + previousArticleId: null, + nextArticleId: null + }, + { + id: "local-4", + title: "Road work begins on I-76 overnight lane closures", + body: "PennDOT's feed indicates closures will run nightly between 9pm and 5am through the end of the month.", + heroImage: { url: img("road-work"), sourceItemId: "ci-430", selectionReason: "Single source" }, + video: null, + category: ["Local"], + geo: "philadelphia", + eventId: null, + sourceCount: 1, + sources: [{ itemId: "ci-430", sourceName: "PennDOT feed", link: "https://example.com/penndot/1", publishedAt: hoursAgo(15) }], + publishedAt: hoursAgo(15), + updatedAt: hoursAgo(15), + mergeConfidence: 1.0, + tags: [], + threadId: "thread-road", + previousArticleId: null, + nextArticleId: null + } +]; + +const tags = [ + { id: "tag-trade-talks", label: "US-EU Trade Talks 2026", slug: "us-eu-trade-talks-2026", articleCount: 2, status: "active" }, + { id: "tag-trump", label: "President Trump", slug: "president-trump", articleCount: 1, status: "active" }, + { id: "tag-ai-models", label: "Open-source AI models", slug: "open-source-ai-models", articleCount: 1, status: "active" } +]; + +const events = [ + { id: "evt-iran", name: "Iran war", active: true, cadence: "daily" }, + { id: "evt-fed", name: "Fed rate decisions", active: true, cadence: "continuous" } +]; + +module.exports = { articles, tags, events }; diff --git a/mock-backend/package-lock.json b/mock-backend/package-lock.json new file mode 100644 index 0000000..67ff8f4 --- /dev/null +++ b/mock-backend/package-lock.json @@ -0,0 +1,889 @@ +{ + "name": "mock-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mock-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/mock-backend/package.json b/mock-backend/package.json new file mode 100644 index 0000000..8543495 --- /dev/null +++ b/mock-backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "mock-backend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "node server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1" + } +} diff --git a/mock-backend/server.js b/mock-backend/server.js new file mode 100644 index 0000000..b6d3b3d --- /dev/null +++ b/mock-backend/server.js @@ -0,0 +1,121 @@ +// Mock backend — implements the public API surface from homefeed-data-schema.md +// so the frontend can be built and tested before the real backend exists. +// GET /api/feed?category=&geo=&eventId=&tag= +// GET /api/article/:id +// GET /api/events +// GET /api/tags + +const express = require("express"); +const cors = require("cors"); +const { articles, tags, events } = require("./data"); +const admin = require("./admin-data"); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +const PORT = process.env.PORT || 4000; + +app.get("/api/feed", (req, res) => { + const { category, geo, eventId, tag } = req.query; + let results = articles; + + if (category) { + results = results.filter((a) => a.category.some((c) => c.toLowerCase() === String(category).toLowerCase())); + } + if (geo) { + results = results.filter((a) => a.geo === geo); + } + if (eventId) { + results = results.filter((a) => a.eventId === eventId); + } + if (tag) { + results = results.filter((a) => a.tags.includes(tag)); + } + + results = [...results].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); + res.json(results); +}); + +app.get("/api/article/:id", (req, res) => { + const article = articles.find((a) => a.id === req.params.id); + if (!article) return res.status(404).json({ error: "not found" }); + res.json(article); +}); + +app.get("/api/tags", (req, res) => { + res.json(tags.filter((t) => t.status === "active")); +}); + +app.get("/api/events", (req, res) => { + res.json(events); +}); + +// --- Admin API --- +// No auth in the mock — the real backend enforces session auth on all /api/admin/* routes. + +app.get("/api/admin/settings", (req, res) => { + res.json(admin.getSettings()); +}); + +app.patch("/api/admin/settings", (req, res) => { + res.json(admin.updateSettings(req.body)); +}); + +app.get("/api/admin/sources", (req, res) => { + res.json(admin.getSources()); +}); + +app.post("/api/admin/sources", (req, res) => { + res.status(201).json(admin.addSource(req.body)); +}); + +app.patch("/api/admin/sources/:id", (req, res) => { + const updated = admin.updateSource(req.params.id, req.body); + if (!updated) return res.status(404).json({ error: "not found" }); + res.json(updated); +}); + +app.delete("/api/admin/sources/:id", (req, res) => { + admin.deleteSource(req.params.id); + res.status(204).end(); +}); + +app.get("/api/admin/events", (req, res) => { + res.json(admin.getEvents()); +}); + +app.post("/api/admin/events", (req, res) => { + res.status(201).json(admin.addEvent(req.body)); +}); + +app.patch("/api/admin/events/:id", (req, res) => { + const updated = admin.updateEvent(req.params.id, req.body); + if (!updated) return res.status(404).json({ error: "not found" }); + res.json(updated); +}); + +app.delete("/api/admin/events/:id", (req, res) => { + admin.deleteEvent(req.params.id); + res.status(204).end(); +}); + +app.get("/api/admin/models", (req, res) => { + res.json(admin.getModels()); +}); + +app.get("/api/admin/ai-status", (req, res) => { + // Simulates pinging the configured Ollama host + const { aiServiceHost, aiServicePort } = admin.getSettings(); + res.json({ + connected: true, + host: aiServiceHost, + port: aiServicePort, + ramGB: 48, + gpu: "none reported" + }); +}); + +app.listen(PORT, () => { + console.log(`Mock backend running at http://localhost:${PORT}`); +});