SPB Git

spb/websensor

Public

WebSensor 0.2 — signal intelligence upgrade

Registry: 7 depth fragments (40–46: AI frontier, cloud infrastructure, cybersecurity,
finance & markets, governments, science & health, transport/telecom/sports/news) →
3 167 organizations / 6 320 validated sensors (was 2 681 / 4 052); seeds carry
country, language and first_party; sync infers country/language by TLD and a
0–3 scheduling priority.

Core: semantic diff (noise classes vs pricing/policy/product/personnel, field-level
before → after with % deltas), WebSensor Signal Score with explainable reasons,
impact / velocity / anomaly scores, breaking state, entity rank, ~40 new event
classes (zero_day, active_exploitation, supply_chain_attack, guidance, buyback,
sanction, legislation, outbreak, grounding, service_shutdown…), search syntax
(entity: type: after:7d silent: importance:>70 country:), shared registry schema.

Engine: noise never becomes an event, routine firehose batches damped, fingerprint
idempotency, ambiguous-alias rule for entity resolution (+ relink-entities CLI),
silent-change bar (first-party · eligible type · importance ≥ 45), clusters with
propagation timeline, first-party/external counts, velocity, lead time and
breaking/developing/confirmed states, server-side alerts with HMAC-signed
webhooks, raw-body retention with reference counting, priority-aware scheduler
with per-domain circuit breaker and Redis heartbeat, UTF-16 BOM transcoding.

API: new filters (signal_min, group, first_party, confirmed, country, language,
change_class, q syntax, order=signal), /breaking /pulse /radar /clusters/:slug
/entities/rank /countries /categories/:channel /sensors/:id/snapshots, owner
routes (bookmarks, saved views, notifications, custom URL monitors with SSRF
checks), admin routes behind WS_ADMIN_TOKEN (ops, sensor/source actions,
connector test, YAML/JSON bulk import), TTL cache, WebSocket protocol 2 with
stream ids and replay.

Web: terminal-style design system (badges, density modes, skeletons, sparklines,
heatmaps), live system strip, ⌘K command palette, intelligence drawer, live feed
v2 (URL-synced filters, pause, "↑ N new events", replay), pages /live /pulse
/radar /entity/[id] /cluster/[slug] /country /bookmarks /monitors /ops, redesigned
breaking / silent / explore / event / source / sensor / search / alerts /
watchlists / health / api docs, OG image per event, sitemap, loading skeletons.

Migrations 0005 (additive schema) and 0006 (backfills). Docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 3c59155 Browse files

102 changed files +16,500 −1,428

102 changed files
modified .env.example +15 −1
@@ -26,5 +26,19 @@ WS_LLM_MIN_IMPORTANCE=35
26 26
27 # Engine tuning 27 # Engine tuning
28 WS_FETCH_CONCURRENCY=16 28 WS_FETCH_CONCURRENCY=16
29−WS_USER_AGENT=WebSensorBot/0.1 (+https://www.websensor.io/bot) 29+WS_USER_AGENT="WebSensorBot/0.2 (+https://www.websensor.io/bot; contact@websensor.io)"
30 WS_SOURCES_FILE=./config/sources.yaml 30 WS_SOURCES_FILE=./config/sources.yaml
31+WS_PER_HOST_CONCURRENCY=2
32+WS_MEANINGFUL_SIGNAL=0.32
33+# Silent-change bar: only silent-eligible types with importance >= this are flagged (spec §23)
34+WS_SILENT_MIN_IMPORTANCE=45
35+# Storage lifecycle (spec §58): raw bodies of unchanged snapshots are dropped after N days (canonical + hashes kept);
36+# snapshots referenced by an event are never pruned. WS_RETENTION=0 disables the job.
37+WS_RETENTION=1
38+WS_RETENTION_RAW_DAYS=21
39+WS_RETENTION_CHANGED_RAW_DAYS=60
40+
41+# API — internal operations (/api/v1/admin/*, /ops). Unset = admin API disabled.
42+WS_ADMIN_TOKEN=
43+# Custom URL monitors per anonymous owner (spec §45)
44+WS_MONITORS_PER_OWNER=5
modified CLAUDE.md +93 −80
@@ -1,97 +1,110 @@
1 # WebSensor — repository guide 1 # WebSensor — repository guide
2 2
3 WebSensor (www.websensor.io) is a real-time web intelligence platform: a global sensor network for the changing 3 WebSensor (www.websensor.io) is a real-time web intelligence platform: a global sensor network for the changing
4−Web. It monitors official public sources, detects meaningful changes, classifies them, links entities, scores 4+Web. It monitors official public sources, detects meaningful changes, classifies them semantically, links
5−importance, preserves evidence and publishes events through a WebSocket feed. The product brief that drove the 5+entities, scores importance / confidence / novelty / impact / velocity / anomaly into a WebSensor Signal Score,
6−design is `docs/PRODUCT-BRIEF.md`; this file is the working guide for the code. 6+preserves immutable evidence, clusters signals into events with propagation timelines, and publishes them through
7+a replayable WebSocket feed. Product brief: `docs/PRODUCT-BRIEF.md`; architecture: `docs/ARCHITECTURE.md`. This file
8+is the working guide for the code (v0.2, 2026-09-11).
7 9
8 ## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15) 10 ## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15)
9−- `packages/core` — taxonomy (event types + intrinsic severity, tiers, categories), ids, SSRF policy 11+- `packages/core` — taxonomy (event types incl. cyber/finance/government/health/transport classes, event groups,
10− (`assertUrlAllowed`, `safeLookup`), hashing (sha256, simhash, shingles/Jaccard), canonical extraction 12+ silent-eligible types, change classes, cluster states, countries), ids, SSRF policy (`assertUrlAllowed`,
11− (`canonicalizeHtml`: strips scripts/nav/footer/cookie chrome, timestamps, tokens, counters), diff engines 13+ `safeLookup`), hashing, canonical extraction, diff engines (text / json / keyed list), stage-1 heuristics
12− (text / json / keyed list), stage-1 heuristics (`evaluateChange`, `describeChange`), scoring 14+ (`evaluateChange`, `describeChange`, `humanizeUrl`), **semantic diff** (`semantic.ts`: `classifyChange` →
13− (importance components, confidence, novelty helpers, trending, activity anomaly), adaptive schedule. 15+ cosmetic / navigation / timestamp / advertisement / boilerplate vs meaningful / pricing / policy / product /
14− `@websensor/core/client` is the browser-safe subset (no `node:` imports) — client components must import it. 16+ personnel; `extractFieldChanges` → field-level before → after pairs with % deltas), scoring (`scoring.ts`:
15−- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) + 17+ importance components, confidence, impact, velocity, **WebSensor Signal Score** with explainable reasons,
16− Drizzle schema for typed access. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays 18+ `breakingState`, entity rank, daily anomaly), adaptive schedule, **search syntax** (`search.ts`: `entity:` `type:`
17− (Drizzle spreads arrays into parameter lists). 19+ `after:7d` `silent:true` `importance:>70` `country:CA` …), registry seed schema (`registry-schema.ts`, shared by
18−- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`, zstd via `node:zlib`). Raw 20+ engine, validator and the admin import endpoint). `@websensor/core/client` is the browser-safe subset.
19− bodies, canonical representations and full diffs live there, never in Postgres. Interface ready for S3/MinIO. 21+- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) + Drizzle
20−- `packages/connectors` — connector SDK (`WebSensorConnector.fetch/normalize`), safe fetcher (conditional GET, 22+ schema. `0005_intelligence.sql` added provenance (first_party / country / language / kind / owner_token on
21− manual redirects validated per hop, HTTP/2→1.1 pin on NGHTTP2 errors, browser-UA second attempt on 23+ sources), sensor lifecycle (`status`, `priority`, `validated_at`), semantic class + field changes on changes,
22− 403/resets, size + time limits, `keepAllHeaders`), 16 connector families: `http` (HTML/JSON/text/HEAD), `rss` 24+ signal/velocity/impact/anomaly scores, fingerprints, score reasons on events, cluster propagation columns,
23− (RSS/Atom/RDF/JSON Feed), `sitemap` (index + news), `statuspage` (Atlassian v2 summary), `statusjson` (Instatus / 25+ `entity_daily`, `source_daily`, `bookmarks`, `saved_views`, alert channel config, notification delivery.
24− incident.io / Status.io), `github` (releases/tags/commits Atom, advisories REST), `jsonlist` (keyed records from 26+ `0006_backfill_states.sql` backfills them. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays.
25− any JSON API; `{now-2h}` placeholders), `package` (npm/PyPI/crates/RubyGems/NuGet/Packagist/Hex/Go proxy/Homebrew/ 27+- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`). Raw bodies, canonical forms and full
26− Docker Hub version streams), `edgar` (SEC submissions → filings, 8-K items decoded), `openapi` (API contract 28+ diffs live there, never in Postgres.
27− fingerprints), `csv` (open-data rows, `tail`), `pdf` (pdf.js text via unpdf), `dns` (A/AAAA/NS/MX/TXT/CAA/SOA/DMARC), 29+- `packages/connectors` — connector SDK (`fetch/normalize`), safe fetcher (conditional GET, per-hop SSRF checks,
28− `tls` (certificate identity, chain, ALPN, expiry), `headers` (security/infra response headers), `rdap` (registrar, 30+ HTTP/2→1.1 pin, browser-UA retry, UTF-16 BOM transcoding, `postJson` for signed webhooks), 16 connector families
29− EPP status, expiry via IANA bootstrap), `discovery` (robots sitemaps, `<link rel=alternate>`, well-known feed 31+ (`http rss sitemap statuspage statusjson github jsonlist package edgar openapi csv pdf dns tls headers rdap`) +
30− paths, linked status pages — every candidate is fetched and parsed), `scrapfly` (fallback, budgeted, only when 32+ `discovery` + `scrapfly` fallback. `jsonlist.urlTemplate` supports `{key}` (path separators kept) and
31− `fallback.scrapfly` is set on the source). `dns://` and `tls://` sensor URLs are accepted (host only). 33+ `{field.path}`.
32−- `apps/engine` — scheduler (`FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency), pipeline 34+- `apps/engine` — scheduler (priority-aware `FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency,
33− (fetch → normalize → snapshot → diff → heuristics → change → event: entities, novelty, LLM interpretation, 35+ **domain circuit breaker**, heartbeat to Redis `ws:engine:status`), pipeline (fetch → normalize → snapshot → diff →
34− clustering, importance/confidence, silent-change detection, publish to Redis), registry sync from 36+ heuristics + semantic class → change → event: fingerprint idempotency, entity resolution with ambiguous-alias
35− `config/sources.yaml` + `config/sources.d/`, discovery, connector health rollups, Prometheus metrics on :8262. 37+ rules, novelty, optional Claude interpretation, impact / anomaly / signal scores, silent-change bar, clustering
36− CLI: `src/cli.ts` (`sync`, `discover`, `probe <domain>`, `run-once <sensor>`, `run-due [n]`); `src/validate.ts` 38+ with propagation timeline + lead time + breaking state, entity/source daily counters, alert evaluation + webhook
37− (registry validator, see below). 39+ delivery, Redis stream + pub/sub), registry sync (`config/sources.yaml` + `config/sources.d/`), discovery,
38−- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*`, WebSocket `/api/v1/live` (Redis pub/sub fan-out, 40+ connector health rollups, **retention** (`retention.ts`), Prometheus metrics :8262.
39− channels `events:*`, `entity:*`, `source:*`, `watchlist:*`), `/api/v1/feed.rss`, `/api/health` `/api/ready` `/api/metrics`, apex→www 41+ CLI: `src/cli.ts` (`sync`, `discover`, `probe`, `run-once`, `run-due`, `relink-entities [days]`, `prune-blobs`,
40− redirect, and a reverse proxy to the Next.js app for everything else. 42+ `refresh-clusters`, `llm-test`); `src/validate.ts` (registry validator).
41−- `apps/web` — Next.js 16 frontend (:8261, loopback). Pages: live, breaking, explore, sources, entities, 43+- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*` (`routes.ts` public, `routes-user.ts` owner-scoped:
42− timelines, silent changes, watchlists, alerts, event detail with diff viewer, health, API docs. 44+ watchlists / alerts (+webhook) / notifications / bookmarks / saved views / custom monitors, `routes-admin.ts`
43−- `config/sources.yaml` — the founding 271-organization registry (incl. governments and news media; news sources 45+ behind `WS_ADMIN_TOKEN`: ops, sensor & source actions, connector test, bulk import), intelligence read-models
44− run with `llm: false`, heuristics only) + `config/sources.d/*.yaml` fragments, one per **site class** (open source, 46+ (`intel.ts`: breaking desk, pulse, radar, entity insights, rankings, cluster detail, country & category desks),
45− central banks & finance, energy/climate/weather, telecom & internet infrastructure, retail/consumer/travel, 47+ TTL cache (`cache.ts`), WebSocket `/api/v1/live` protocol 2 (`live.ts`: `sid` on every frame, `{"since"}` replay
46− gaming & entertainment, universities & research, international orgs/NGOs/standards, sports, crypto, 48+ from the durable stream, channels `events:* group:* country:* state:* type:* entity:* source:* watchlist:*`),
47− transport/aviation/space, consumer safety/food/agri, housing/labour/open data, enterprise SaaS, health systems & 49+ `/api/v1/feed.rss`, `/api/health|ready|metrics`, apex→www redirect, reverse proxy to Next.
48− medtech, politics/elections/courts, EDGAR filings, package registries, web posture, OpenAPI, status JSON, 50+- `apps/web` — Next.js 16 (:8261 loopback). Design system in `components/ui.tsx` (Panel, Badge, Score, Tabs,
49− documents & data). Fragments are merged by `apps/engine/src/seeds.ts` in file-name order; an entry with 51+ Sparkline, Heatmap, Skeleton…), `prefs.tsx` (density compact/normal/comfortable, pause), `event-drawer.tsx`
50− `extend: true` adds sensors/products/aliases to a source declared earlier. Authoring guide + connector 52+ (intelligence panel), `command-palette.tsx` (⌘K), `live-strip.tsx`, `live-feed.tsx` v2 (URL-synced filters,
51− cheat-sheet: `docs/registry/AUTHORING.md`; `docs/connectors/*.md` document each connector family. 53+ pause, "↑ N new events", replay), `field-changes.tsx`. Pages: `/` `/live` `/breaking` `/silent` `/pulse` `/radar`
52−- Registry validator (no DB): `node node_modules/tsx/dist/cli.mjs apps/engine/src/validate.ts [fragment.yaml] [--all] 54+ `/explore` `/entity/[id]` (`/company` redirects) `/cluster/[slug]` `/source/[id]` `/sensor/[id]` `/category/[c]`
53− [--source id] [--connector key] [--json report.json]` runs every curated sensor through its connector and prints 55+ `/country/[slug]` `/event/[slug]` (+ OG image) `/bookmarks` `/watchlists` `/alerts` `/monitors` `/ops` `/health`
54− OK/WARN/FAIL; `--probe <domain>` is a discovery dry-run. `scripts/prune-fragment.py` removes FAIL/WARN sensors from 56+ `/api`.
55− a fragment using the JSON report; `scripts/gen-web-posture.py` regenerates the web-posture fragment. 57+- `config/sources.yaml` (founding registry) + `config/sources.d/*.yaml` fragments merged in file-name order
58+ (`extend: true` adds to an earlier source). Fragments 10–35 = site classes; **40–46 (2026-09-11)** = depth: AI
59+ frontier, cloud infrastructure, cybersecurity, finance & markets, governments, science & health, transport /
60+ telecom / sports / news. Seeds may carry `country:` (ISO-2, `EU`, `INT`), `language:` and `first_party: false`
61+ (media). Guide: `docs/registry/AUTHORING.md`. Validator: `node node_modules/tsx/dist/cli.mjs
62+ apps/engine/src/validate.ts <fragment> [--all] [--json report.json]` — nothing enters the registry without OK.
56 63
57 ## Rules 64 ## Rules
58−- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets — goes through 65+- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets, webhooks, custom
59− `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net` and single-label hosts are 66+ monitors — goes through `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net`,
60− blocked; the dispatcher's DNS lookup only returns approved addresses. 67+ single-label hosts and NAT64-embedded private IPv4 are blocked.
61−- Raw evidence is immutable: snapshots and diffs are never rewritten. Re-interpretation creates a new row in 68+- Raw evidence is immutable: snapshots and diffs are never rewritten. Retention may drop the RAW body of
62− `interpretations` (versioned); `events.interpretation` holds the latest. 69+ snapshots that are not referenced by any event (canonical form and hashes stay); event snapshots are kept forever.
63−- LLM cost control: heuristics first; Claude is called only for candidates above `WS_LLM_MIN_IMPORTANCE`, with a 70+ Re-interpretation creates a new `interpretations` row.
64− daily call budget; the deep model only above `WS_LLM_DEEP_MIN_IMPORTANCE`. Output is strict JSON 71+- Noise never becomes an event: changes classified cosmetic / navigation / timestamp / advertisement /
65− (`output_config.format`), never free text. No key → heuristics only, the system still works. 72+ boilerplate are stored as changes only. Routine batches from firehose feeds are damped. Ingestion is idempotent
66−- Feeds: items are "new" only if their key was never seen (sensor `state.seenKeys`) and they are not older than 73+ (`events.fingerprint` = sensor + before/after canonical hashes).
67− 14 days; items scrolling out of the window are never "removed". Sitemaps/statuspages: a >50 % shrink is 74+- Silent change = first-party source · silent-eligible type (pricing, terms, policy, API, availability, docs,
68− treated as a partial response, not mass deletion. A 404 becomes `page_removed` only after 75+ shutdown, feature removed, leadership, page removed, crawler policy…) · no matching announcement within 12 h ·
69− `WS_DELETE_CONFIRMATIONS` checks separated by `WS_DELETE_SEPARATION_MIN`. 76+ importance ≥ `WS_SILENT_MIN_IMPORTANCE` · not a noise class. Never asserted as "unannounced" otherwise.
70−- Never label inference as fact: events carry `evidence_label` (OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED) 77+- Never label inference as fact: `evidence_label` OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED; AI text is
71− and the interpretation keeps `observed` and `inferred` apart. Silent changes are flagged, never asserted as 78+ labelled analysis; `score_reasons` explain every signal score. First-party evidence outweighs media reports
72− "unannounced" without checking recent announcement-type events of the same source. 79+ (`sources.first_party`, media seeds carry `first_party: false`).
73−- Every sensor in the registry has been fetched and parsed successfully by the validator before being committed; 80+- Entity aliases that are ordinary words (`first`, `has`, `who`, `make`…) only match as exact upper-case acronyms
74− a fragment that fails validation is not merged. Never add sensors to the registry without running it. 81+ in text (`AMBIGUOUS_ALIASES` in `apps/engine/src/entities.ts`); run `cli.ts relink-entities 7` after changing
75−- SSRF policy: NAT64 addresses (`64:ff9b::/96`) are judged by the embedded IPv4 address (IPv6-only networks with 82+ alias rules.
76− 464XLAT resolve every IPv4-only host that way), not blocked wholesale. 83+- LLM cost control: heuristics first; Claude only above `WS_LLM_MIN_IMPORTANCE`, daily budget, strict JSON output;
77−- Do not add sensors that are blocked with 403 by design (Akamai/Cloudflare bot management) unless the source 84+ `llm: false` on firehoses (news, arXiv, NVD, package streams). No key → the system still works.
78− has `fallback.scrapfly: true` and a tier ≥ C; Scrapfly is a budgeted fallback, not the foundation. 85+- Feeds: items are "new" only if never seen and < 14 days old; >50 % list shrink = partial response; 404 becomes
79−- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`…). 86+ `page_removed` after `WS_DELETE_CONFIRMATIONS` separated checks.
87+- Admin API is off unless `WS_ADMIN_TOKEN` is set; custom monitors are limited per owner and never appear in public
88+ feeds (`sources.kind = 'custom'`).
89+- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`, `wl_`, `alr_`…).
80 90
81 ## Dev 91 ## Dev
82 ``` 92 ```
83 createdb websensor && cp .env.example .env 93 createdb websensor && cp .env.example .env
84 pnpm install 94 pnpm install
85−pnpm db:migrate # or let the engine migrate on start 95+pnpm db:migrate # or let the engine migrate on start
86−npx tsx apps/engine/src/cli.ts sync # registry → DB 96+npx tsx apps/engine/src/cli.ts sync # registry → DB
87 npx tsx apps/engine/src/cli.ts run-due 50 97 npx tsx apps/engine/src/cli.ts run-due 50
88−pnpm dev:api · pnpm dev:engine · pnpm dev:web 98+pnpm dev:api · pnpm dev:engine · pnpm dev:web # open the site through the gateway (:8260) so /api is same-origin
89 pnpm test · pnpm typecheck 99 pnpm test · pnpm typecheck
90 ``` 100 ```
91−Run CLI/engine from the repo root (config paths are relative to cwd). 101+Run CLI/engine from the repo root. A copy of production data for UI work:
102+`ssh M4M64b 'pg_dump websensor --data-only -t events -t …' | psql websensor` then `cli.ts sync`.
92 103
93 ## Deploy (MacLustr) 104 ## Deploy (MacLustr)
94−`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `deploy/websensor.mld.json` (secrets only 105+`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `M1M32:~/dispatch/apps/websensor.json`
95−on M1M32). Processes: `websensor-api` (:8260, ngrok www.websensor.io), `websensor-web` (:8261 loopback), 106+(secrets incl. `WS_ADMIN_TOKEN`; gitignored copy `deploy/websensor.mld.json`). Processes: `websensor-api` (:8260,
96−`websensor-engine` (metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. 107+public via MacLustr Tunnel `www.websensor.io` on BHS64), `websensor-web` (:8261 loopback), `websensor-engine`
97−See `deploy/README.md`. 108+(metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. After a deploy that
109+changes alias or scoring rules: `cli.ts relink-entities 7` and `cli.ts refresh-clusters` on the node. See
110+`deploy/README.md`.
modified apps/api/package.json +2 −0
@@ -14,6 +14,7 @@
14 "@fastify/rate-limit": "^10.3.0", 14 "@fastify/rate-limit": "^10.3.0",
15 "@fastify/reply-from": "^12.6.5", 15 "@fastify/reply-from": "^12.6.5",
16 "@fastify/websocket": "^11.2.0", 16 "@fastify/websocket": "^11.2.0",
17+ "@websensor/connectors": "workspace:*",
17 "@websensor/core": "workspace:*", 18 "@websensor/core": "workspace:*",
18 "@websensor/db": "workspace:*", 19 "@websensor/db": "workspace:*",
19 "@websensor/store": "workspace:*", 20 "@websensor/store": "workspace:*",
@@ -24,6 +25,7 @@
24 "prom-client": "^15.1.0", 25 "prom-client": "^15.1.0",
25 "tsx": "^4.20.0", 26 "tsx": "^4.20.0",
26 "ws": "^8.21.3", 27 "ws": "^8.21.3",
28+ "yaml": "^2.8.0",
27 "zod": "^4.0.0" 29 "zod": "^4.0.0"
28 }, 30 },
29 "devDependencies": { 31 "devDependencies": {
added apps/api/src/cache.ts +36 −0
@@ -0,0 +1,36 @@
1+/**
2+ * Tiny in-process TTL cache with request coalescing (spec §59). Used for aggregates only
3+ * (stats, trending, pulse, radar, rankings) — never for the live feed itself.
4+ */
5+const store = new Map<string, { at: number; ttl: number; value: unknown; pending?: Promise<unknown> }>();
6+
7+export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
8+ const now = Date.now();
9+ const hit = store.get(key);
10+ if (hit && now - hit.at < hit.ttl) return hit.value as T;
11+ if (hit?.pending) return hit.pending as Promise<T>;
12+ const pending = fn()
13+ .then((value) => {
14+ store.set(key, { at: Date.now(), ttl: ttlMs, value });
15+ return value;
16+ })
17+ .catch((e) => {
18+ // serve stale on failure when we have anything at all
19+ if (hit) {
20+ store.set(key, { ...hit, pending: undefined });
21+ return hit.value as T;
22+ }
23+ store.delete(key);
24+ throw e;
25+ });
26+ store.set(key, { at: hit?.at ?? 0, ttl: hit?.ttl ?? ttlMs, value: hit?.value, pending });
27+ return pending;
28+}
29+
30+export function cacheStats(): { entries: number; keys: string[] } {
31+ return { entries: store.size, keys: [...store.keys()].slice(0, 50) };
32+}
33+
34+export function invalidate(prefix: string): void {
35+ for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k);
36+}
modified apps/api/src/config.ts +5 −1
@@ -10,5 +10,9 @@ export const config = {
10 publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260", 10 publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260",
11 canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io", 11 canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io",
12 redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0", 12 redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0",
13− version: "0.1.0", 13+ /** Admin endpoints (`/api/v1/admin/*`) are enabled only when this token is set; sent as `X-WebSensor-Admin`. */
14+ adminToken: env.WS_ADMIN_TOKEN ?? "",
15+ /** Custom URL monitors per anonymous owner (spec §45). */
16+ monitorsPerOwner: Number(env.WS_MONITORS_PER_OWNER ?? 5),
17+ version: "0.2.0",
14 }; 18 };
added apps/api/src/intel.ts +238 −0
@@ -0,0 +1,238 @@
1+import { COUNTRIES, dailyAnomaly, entityRankScore, EVENT_GROUPS, FEED_CHANNELS } from "@websensor/core";
2+import { db, sql, textArray } from "@websensor/db";
3+import { cached } from "./cache";
4+import { EVENT_SELECT, listEvents } from "./queries";
5+
6+/**
7+ * Intelligence read-models (spec §34–39, §101–105): breaking desk, pulse, radar, entity insights,
8+ * rankings, cluster propagation, country and category desks. Aggregates are cached briefly.
9+ */
10+
11+const CLUSTER_SELECT = sql`c.id, c.slug, c.title, c.summary, c.primary_event_id, c.entity_ids, c.categories, c.event_count, c.max_importance, c.first_at, c.last_at, c.source_count, c.first_party_count, c.external_count, c.velocity, c.state, c.lead_time_ms, c.first_party_at, c.first_external_at`;
12+
13+async function clusterPrimaryEvents(where: ReturnType<typeof sql>, order: ReturnType<typeof sql>, limit: number): Promise<Record<string, unknown>[]> {
14+ const rows = await db.execute<Record<string, unknown>>(sql`
15+ select ${CLUSTER_SELECT},
16+ (select row_to_json(x) from (select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = c.primary_event_id) x) as event,
17+ (select json_agg(json_build_object('id', s2.id, 'name', s2.name, 'domain', s2.domain, 'first_party', s2.first_party) order by s2.name) from (select distinct s3.id, s3.name, s3.domain, s3.first_party from events e3 join sources s3 on s3.id = e3.source_id where e3.cluster_id = c.id limit 12) s2) as sources
18+ from event_clusters c where ${where} order by ${order} limit ${limit}`);
19+ return rows.rows;
20+}
21+
22+export async function breakingDesk(): Promise<Record<string, unknown>> {
23+ return cached("breaking", 8_000, async () => {
24+ const [breaking, developing, confirmed, watching] = await Promise.all([
25+ clusterPrimaryEvents(sql`c.state = 'breaking' and c.last_at >= now() - interval '24 hours'`, sql`c.max_importance desc, c.velocity desc, c.last_at desc`, 20),
26+ clusterPrimaryEvents(sql`c.state = 'developing' and c.last_at >= now() - interval '24 hours'`, sql`c.velocity desc, c.max_importance desc, c.last_at desc`, 20),
27+ clusterPrimaryEvents(sql`c.state = 'confirmed' and c.last_at >= now() - interval '48 hours'`, sql`c.last_at desc`, 20),
28+ listEvents({ limit: 25, order: "signal", signal_min: 60, after: new Date(Date.now() - 12 * 3600e3).toISOString() }).then((r) => r.items.filter((e) => !["breaking", "developing", "confirmed"].includes(String((e.cluster as { state?: string } | null)?.state ?? "")))),
29+ ]);
30+ return { breaking_now: breaking, developing, recently_confirmed: confirmed, watching, generated_at: new Date().toISOString() };
31+ });
32+}
33+
34+export async function pulse(): Promise<Record<string, unknown>> {
35+ return cached("pulse", 10_000, async () => {
36+ const desks = ["ai", "cyber", "finance", "government", "infrastructure", "health", "science", "products"];
37+ const [activity, byDesk, rising, anomalies, silent, incidents, breaking, groups, totals] = await Promise.all([
38+ db.execute<Record<string, unknown>>(sql`
39+ with b as (select generate_series(date_trunc('hour', now() at time zone 'UTC') - interval '6 hours', date_trunc('minute', now() at time zone 'UTC'), interval '15 minutes') as bucket),
40+ ev as (select to_timestamp(floor(extract(epoch from detected_at) / 900) * 900) at time zone 'UTC' as bk, count(*) as n from events where detected_at >= now() - interval '7 hours' group by 1),
41+ ch as (select to_timestamp(floor(extract(epoch from detected_at) / 900) * 900) at time zone 'UTC' as bk, count(*) as n from changes where detected_at >= now() - interval '7 hours' group by 1)
42+ select to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:00"Z"') as t, coalesce(ev.n, 0)::int as events, coalesce(ch.n, 0)::int as changes
43+ from b left join ev on ev.bk = b.bucket left join ch on ch.bk = b.bucket order by b.bucket`).then((r) => r.rows).catch(() => []),
44+ Promise.all(desks.map(async (d) => ({ desk: d, items: (await listEvents({ limit: 5, order: "signal", category: d, after: new Date(Date.now() - 12 * 3600e3).toISOString() })).items }))),
45+ db.execute<Record<string, unknown>>(sql`
46+ with cur as (select ee.entity_id, count(*) n from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - interval '3 hours' group by 1),
47+ prev as (select ee.entity_id, count(*) n from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - interval '27 hours' and e.detected_at < now() - interval '3 hours' group by 1)
48+ select en.id, en.name, en.type, cur.n::int as events_3h, coalesce(prev.n,0)::int as events_prev_24h, round((cur.n::float / greatest(0.125, coalesce(prev.n,0)/8.0))::numeric, 1)::float as acceleration
49+ from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id where cur.n >= 2 order by acceleration desc, cur.n desc limit 10`).then((r) => r.rows),
50+ db.execute<Record<string, unknown>>(sql`
51+ with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id),
52+ base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id)
53+ select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
54+ round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score,
55+ (case when coalesce(base.per_hour,0) > 0 then round(((cur.n/2.0/base.per_hour - 1) * 100)::numeric) else null end)::int as pct_vs_baseline
56+ from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id where so.kind = 'registry' order by activity_score desc limit 8`).then((r) => r.rows),
57+ listEvents({ limit: 8, silent_change: true, order: "signal", after: new Date(Date.now() - 24 * 3600e3).toISOString() }).then((r) => r.items),
58+ listEvents({ limit: 8, group: "reliability", order: "recent", after: new Date(Date.now() - 6 * 3600e3).toISOString() }).then((r) => r.items),
59+ clusterPrimaryEvents(sql`c.state in ('breaking','developing') and c.last_at >= now() - interval '24 hours'`, sql`(c.state = 'breaking') desc, c.max_importance desc, c.velocity desc`, 8),
60+ db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e where e.detected_at >= now() - interval '24 hours' group by 1`).then((r) => {
61+ const byGroup: Record<string, number> = {};
62+ for (const row of r.rows) {
63+ const g = Object.entries(EVENT_GROUPS).find(([, spec]) => spec.types.includes(String(row.event_type)))?.[0] ?? "web";
64+ byGroup[g] = (byGroup[g] ?? 0) + Number(row.n);
65+ }
66+ return byGroup;
67+ }),
68+ db.execute<Record<string, unknown>>(sql`select (select count(*) from events where detected_at >= now() - interval '1 hour')::int as events_1h, (select count(*) from changes where detected_at >= now() - interval '1 hour')::int as changes_1h, (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes')::int as checks_5m, (select count(distinct source_id) from events where detected_at >= now() - interval '24 hours')::int as active_sources_24h, (select count(distinct country) from events where detected_at >= now() - interval '24 hours' and country is not null)::int as active_countries_24h`).then((r) => r.rows[0]),
69+ ]);
70+ return { activity, desks: byDesk, rising_entities: rising, anomalies, silent_changes: silent, infrastructure: incidents, breaking, by_group_24h: groups, totals, generated_at: new Date().toISOString() };
71+ });
72+}
73+
74+/** Weak signals (spec §101): things that are NOT breaking yet but could become important. Clearly labelled as indicators. */
75+export async function radar(): Promise<Record<string, unknown>> {
76+ return cached("radar", 30_000, async () => {
77+ const [quietBursts, silentClusters, docBursts, repoBursts, statusChanges, developing, newSensorsFiring] = await Promise.all([
78+ // Sources far above their baseline but without any high-importance event
79+ db.execute<Record<string, unknown>>(sql`
80+ with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '3 hours' group by s.source_id),
81+ base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id),
82+ hot as (select distinct source_id from events where detected_at >= now() - interval '6 hours' and coalesce(signal_score, importance) >= 75)
83+ select so.id, so.name, so.domain, so.categories, cur.n::int as changes_3h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
84+ round((cur.n/3.0 / greatest(0.02, coalesce(base.per_hour, 0)))::numeric, 1)::float as ratio
85+ from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id
86+ where so.kind = 'registry' and cur.n >= 4 and cur.source_id not in (select source_id from hot) and (coalesce(base.per_hour,0) = 0 or cur.n/3.0 / base.per_hour >= 4)
87+ order by ratio desc limit 12`).then((r) => r.rows),
88+ // Entities with several silent changes in 24 h
89+ db.execute<Record<string, unknown>>(sql`
90+ select en.id, en.name, en.type, count(*)::int as silent_24h, array_agg(distinct e.event_type) as types, max(e.detected_at) as last_at
91+ from events e join event_entities ee on ee.event_id = e.id join entities en on en.id = ee.entity_id
92+ where e.silent_change and e.detected_at >= now() - interval '24 hours' group by en.id, en.name, en.type having count(*) >= 2 order by silent_24h desc, last_at desc limit 12`).then((r) => r.rows),
93+ // Documentation / API modification bursts
94+ db.execute<Record<string, unknown>>(sql`
95+ select s.id, s.name, s.domain, count(*)::int as doc_changes_6h, array_agg(distinct e.event_type) as types, max(e.detected_at) as last_at
96+ from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '6 hours' and e.event_type in ('documentation_change','API_change','api_change','availability_change')
97+ group by s.id, s.name, s.domain having count(*) >= 3 order by doc_changes_6h desc limit 10`).then((r) => r.rows),
98+ // Repository activity bursts (commits/tags/releases)
99+ db.execute<Record<string, unknown>>(sql`
100+ select s.id, s.name, s.domain, count(*)::int as repo_events_6h, max(e.detected_at) as last_at
101+ from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.detected_at >= now() - interval '6 hours' and sen.connector = 'github'
102+ group by s.id, s.name, s.domain having count(*) >= 3 order by repo_events_6h desc limit 10`).then((r) => r.rows),
103+ // Fresh status-page changes below the breaking bar
104+ listEvents({ limit: 10, group: "reliability", after: new Date(Date.now() - 3 * 3600e3).toISOString() }).then((r) => r.items.filter((e) => Number(e.importance) < 80)),
105+ clusterPrimaryEvents(sql`c.state = 'developing' and c.last_at >= now() - interval '12 hours'`, sql`c.velocity desc, c.last_at desc`, 8),
106+ // Sensors that produced their first-ever event in the last 24 h (new coverage lighting up)
107+ db.execute<Record<string, unknown>>(sql`
108+ select sen.id, sen.name, sen.source_id, s.name as source_name, min(e.detected_at) as first_event_at, count(*)::int as events
109+ from events e join sensors sen on sen.id = e.sensor_id join sources s on s.id = sen.source_id
110+ where e.detected_at >= now() - interval '24 hours' and not exists (select 1 from events e2 where e2.sensor_id = sen.id and e2.detected_at < now() - interval '24 hours')
111+ group by sen.id, sen.name, sen.source_id, s.name order by events desc limit 10`).then((r) => r.rows),
112+ ]);
113+ return { unusual_source_activity: quietBursts, silent_clusters: silentClusters, documentation_bursts: docBursts, repository_bursts: repoBursts, status_changes: statusChanges, developing, new_coverage: newSensorsFiring, generated_at: new Date().toISOString(), disclaimer: "Indicators, not facts: each item is a pattern in raw observations that has not (yet) produced a high-importance event." };
114+ });
115+}
116+
117+/** Entity insights (spec §26, §38, §102): heatmap, baseline, anomaly, velocity, rank, most active sensors. */
118+export async function entityInsights(entityId: string): Promise<Record<string, unknown>> {
119+ const [heat, today, last24, prev24, sensorsTop, silent24, breaking24, sources24, rank] = await Promise.all([
120+ db.execute<Record<string, unknown>>(sql`select day::text as day, events, silent, breaking, max_importance from entity_daily where entity_id = ${entityId} and day >= (now() at time zone 'UTC')::date - 34 order by day`).then((r) => r.rows),
121+ db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= date_trunc('day', now() at time zone 'UTC')`).then((r) => Number(r.rows[0]?.n ?? 0)),
122+ db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)),
123+ db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '48 hours' and e.detected_at < now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)),
124+ db.execute<Record<string, unknown>>(sql`select sen.id, sen.name, sen.type, sen.connector, sen.source_id, count(*)::int as events_7d, max(e.detected_at) as last_event_at from events e join event_entities ee on ee.event_id = e.id join sensors sen on sen.id = e.sensor_id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '7 days' group by sen.id, sen.name, sen.type, sen.connector, sen.source_id order by events_7d desc limit 8`).then((r) => r.rows),
125+ db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.silent_change and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)),
126+ db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and coalesce(e.signal_score, e.importance) >= 80 and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)),
127+ db.execute<{ n: string }>(sql`select count(distinct e.source_id)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)),
128+ entityRank(entityId),
129+ ]);
130+ const baselineDays = heat.filter((d) => String(d.day) < new Date().toISOString().slice(0, 10));
131+ const baselinePerDay = baselineDays.length ? baselineDays.reduce((n, d) => n + Number(d.events), 0) / Math.max(baselineDays.length, 7) : 0;
132+ // Rolling 24 h vs the 30-day daily baseline (a UTC "today" window is misleading in the first hours of the day).
133+ const anomaly = dailyAnomaly(last24, baselinePerDay, 24);
134+ const velocity = last24 && prev24 ? Math.round((last24 / prev24) * 100) / 100 : last24 ? 2 : 0;
135+ // Fill the 35-day heatmap with zero days
136+ const map = new Map(heat.map((d) => [String(d.day), d]));
137+ const days: Record<string, unknown>[] = [];
138+ for (let i = 34; i >= 0; i--) {
139+ const d = new Date(Date.now() - i * 86400e3).toISOString().slice(0, 10);
140+ days.push(map.get(d) ?? { day: d, events: 0, silent: 0, breaking: 0, max_importance: 0 });
141+ }
142+ return { heatmap: days, baseline_per_day: Math.round(baselinePerDay * 10) / 10, today, events_24h: last24, events_prev_24h: prev24, velocity_ratio: velocity, anomaly, silent_24h: silent24, breaking_24h: breaking24, sources_24h: sources24, most_active_sensors: sensorsTop, rank };
143+}
144+
145+/** WebSensor entity ranking (spec §105): computed over entities active in the last 7 days, cached 2 minutes. */
146+export async function entityRankings(limit = 100): Promise<Record<string, unknown>[]> {
147+ return cached(`rank:${limit}`, 120_000, async () => {
148+ const rows = await db.execute<Record<string, unknown>>(sql`
149+ with agg as (
150+ select ee.entity_id,
151+ count(*) filter (where e.detected_at >= now() - interval '24 hours') as e24,
152+ count(*) as e7,
153+ avg(coalesce(e.signal_score, e.importance)) as avg_signal,
154+ avg(case when e.evidence_label = 'CONFIRMED' then 1 else 0 end) as confirmed_ratio,
155+ count(distinct e.source_id) as sources,
156+ count(*) filter (where e.silent_change and e.detected_at >= now() - interval '24 hours') as silent24,
157+ count(*) filter (where coalesce(e.signal_score, e.importance) >= 80 and e.detected_at >= now() - interval '24 hours') as breaking24,
158+ max(e.detected_at) as last_at
159+ from events e join event_entities ee on ee.event_id = e.id join sources s on s.id = e.source_id where s.kind = 'registry' and e.detected_at >= now() - interval '7 days' group by ee.entity_id),
160+ base as (select entity_id, avg(events)::float as per_day from entity_daily where day >= (now() at time zone 'UTC')::date - 30 and day < (now() at time zone 'UTC')::date group by entity_id)
161+ select en.id, en.name, en.type, en.domain, en.importance, agg.e24::int as events_24h, agg.e7::int as events_7d, round(agg.avg_signal::numeric,1)::float as avg_signal, round(agg.confirmed_ratio::numeric,2)::float as confirmed_ratio, agg.sources::int as sources, agg.silent24::int as silent_24h, agg.breaking24::int as breaking_24h, agg.last_at, coalesce(base.per_day,0)::float as baseline_per_day
162+ from agg join entities en on en.id = agg.entity_id left join base on base.entity_id = agg.entity_id where en.type <> 'person'`);
163+ const scored = rows.rows.map((r) => ({ ...r, rank_score: entityRankScore({ importance: Number(r.importance), events24h: Number(r.events_24h), events7d: Number(r.events_7d), avgSignal: Number(r.avg_signal), confirmedRatio: Number(r.confirmed_ratio), uniqueSources: Number(r.sources), baselinePerDay: Number(r.baseline_per_day) }) }));
164+ scored.sort((a, b) => b.rank_score - a.rank_score);
165+ return scored.slice(0, limit).map((r, i) => ({ ...r, rank: i + 1 }));
166+ });
167+}
168+
169+export async function entityRank(entityId: string): Promise<{ rank: number | null; total: number; score: number | null }> {
170+ const all = await entityRankings(2000);
171+ const i = all.findIndex((r) => r.id === entityId);
172+ return { rank: i >= 0 ? i + 1 : null, total: all.length, score: i >= 0 ? Number(all[i]!.rank_score) : null };
173+}
174+
175+export async function clusterDetail(idOrSlug: string): Promise<Record<string, unknown> | null> {
176+ const c = (await db.execute<Record<string, unknown>>(sql`select ${CLUSTER_SELECT}, c.timeline from event_clusters c where c.id = ${idOrSlug} or c.slug = ${idOrSlug} limit 1`)).rows[0];
177+ if (!c) return null;
178+ const events = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.cluster_id = ${String(c.id)} order by e.detected_at asc limit 200`);
179+ const ents = (c.entity_ids as string[]).length ? await db.execute<Record<string, unknown>>(sql`select id, name, type, importance from entities where id = any(${textArray(c.entity_ids as string[])}) order by importance desc`) : { rows: [] };
180+ // Propagation timeline (spec §35): offsets from the first signal.
181+ const first = new Date(String(c.first_at)).getTime();
182+ const propagation = events.rows.map((e) => ({ id: e.id, slug: e.slug, at: e.detected_at, offset_ms: new Date(String(e.detected_at)).getTime() - first, source: e.source, sensor: e.sensor, first_party: e.first_party, event_type: e.event_type, importance: e.importance, title: e.title }));
183+ const firstExternal = events.rows.find((e) => e.first_party === false);
184+ const firstParty = events.rows.find((e) => e.first_party !== false);
185+ const leadTime = firstParty && firstExternal ? new Date(String(firstExternal.detected_at)).getTime() - new Date(String(firstParty.detected_at)).getTime() : null;
186+ return { cluster: c, events: events.rows, entities: ents.rows, propagation, lead_time_ms: leadTime ?? c.lead_time_ms ?? null, first_party_signals: events.rows.filter((e) => e.first_party !== false).length, external_signals: events.rows.filter((e) => e.first_party === false).length };
187+}
188+
189+export async function countryList(): Promise<Record<string, unknown>[]> {
190+ return cached("countries", 60_000, async () => {
191+ const rows = await db.execute<Record<string, unknown>>(sql`
192+ select s.country, count(distinct s.id)::int as sources, (select count(*) from events e where e.country = s.country and e.detected_at >= now() - interval '24 hours')::int as events_24h,
193+ (select count(*) from events e where e.country = s.country and e.detected_at >= now() - interval '24 hours' and coalesce(e.signal_score, e.importance) >= 80)::int as breaking_24h
194+ from sources s where s.country is not null and s.enabled and s.kind = 'registry' group by s.country order by events_24h desc, sources desc`);
195+ return rows.rows.map((r) => ({ ...r, name: COUNTRIES[String(r.country)]?.name ?? String(r.country), slug: COUNTRIES[String(r.country)]?.slug ?? String(r.country).toLowerCase(), flag: COUNTRIES[String(r.country)]?.flag ?? "" }));
196+ });
197+}
198+
199+export async function countryDesk(code: string): Promise<Record<string, unknown>> {
200+ const c = code.toUpperCase();
201+ const cats = ["government", "finance", "infrastructure", "health", "news", "cyber", "ai", "science"];
202+ const [breaking, byCategory, sources, byType, silent, recent] = await Promise.all([
203+ listEvents({ limit: 10, country: c, order: "signal", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items),
204+ Promise.all(cats.map(async (cat) => ({ category: cat, items: (await listEvents({ limit: 6, country: c, category: cat })).items }))).then((x) => x.filter((d) => d.items.length)),
205+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.categories, s.first_party, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h from sources s where s.country = ${c} and s.enabled and s.kind = 'registry' order by events_24h desc, s.tier, s.name limit 200`).then((r) => r.rows),
206+ db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where country = ${c} and detected_at >= now() - interval '7 days' group by 1 order by 2 desc limit 20`).then((r) => r.rows),
207+ listEvents({ limit: 8, country: c, silent_change: true }).then((r) => r.items),
208+ listEvents({ limit: 40, country: c }),
209+ ]);
210+ return { country: { code: c, name: COUNTRIES[c]?.name ?? c, flag: COUNTRIES[c]?.flag ?? "" }, breaking, by_category: byCategory, sources, by_type: byType, silent, recent: recent.items, nextCursor: recent.nextCursor };
211+}
212+
213+export async function categoryDesk(channel: string): Promise<Record<string, unknown>> {
214+ const cats = FEED_CHANNELS[channel] ?? [channel];
215+ const [breaking, silent, activeSources, byType, trendingEntities, recent, series] = await Promise.all([
216+ listEvents({ limit: 8, category: channel, order: "signal", after: new Date(Date.now() - 24 * 3600e3).toISOString() }).then((r) => r.items),
217+ listEvents({ limit: 8, category: channel, silent_change: true }).then((r) => r.items),
218+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.first_party, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where ${channel} = any(e.categories) and e.detected_at >= now() - interval '24 hours' and s.kind = 'registry' group by s.id, s.name, s.domain, s.tier, s.first_party order by events_24h desc limit 12`).then((r) => r.rows),
219+ db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where ${channel} = any(categories) and detected_at >= now() - interval '7 days' group by 1 order by 2 desc limit 16`).then((r) => r.rows),
220+ db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, count(*)::int as events_24h, count(distinct e.source_id)::int as sources from events e join event_entities ee on ee.event_id = e.id join entities en on en.id = ee.entity_id where ${channel} = any(e.categories) and e.detected_at >= now() - interval '24 hours' group by en.id, en.name, en.type order by events_24h desc limit 10`).then((r) => r.rows),
221+ listEvents({ limit: 60, category: channel }),
222+ db.execute<Record<string, unknown>>(sql`select to_char(date_trunc('hour', detected_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as t, count(*)::int as n from events where ${channel} = any(categories) and detected_at >= now() - interval '48 hours' group by 1 order by 1`).then((r) => r.rows),
223+ ]);
224+ return { channel, categories: cats, breaking, silent, active_sources: activeSources, by_type: byType, trending_entities: trendingEntities, recent: recent.items, nextCursor: recent.nextCursor, series };
225+}
226+
227+/** Sensors table for a source, with polling metadata the source page shows (spec §27). */
228+export async function sourceSensors(sourceId: string): Promise<Record<string, unknown>[]> {
229+ const r = await db.execute<Record<string, unknown>>(sql`
230+ select id, name, url, type, connector, tier, health, status, priority, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, validated_at,
231+ etag is not null as has_etag, last_modified is not null as has_last_modified, etag, last_modified,
232+ (select count(*) from sensor_runs r where r.sensor_id = sensors.id and r.started_at >= now() - interval '24 hours')::int as checks_24h,
233+ (select count(*) from changes c where c.sensor_id = sensors.id and c.detected_at >= now() - interval '24 hours')::int as changes_24h,
234+ (select count(*) from snapshots sn where sn.sensor_id = sensors.id)::int as snapshot_count,
235+ case when last_check_at is not null and next_check_at is not null then extract(epoch from (next_check_at - last_check_at))::int else null end as current_interval_seconds
236+ from sensors where source_id = ${sourceId} order by priority, tier, name`);
237+ return r.rows;
238+}
modified apps/api/src/live.ts +90 −19
@@ -1,34 +1,63 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import type { WebSocket } from "ws"; 2 import type { WebSocket } from "ws";
3 import Redis from "ioredis"; 3 import Redis from "ioredis";
4−import { FEED_CHANNELS } from "@websensor/core"; 4+import { eventGroupOf, FEED_CHANNELS } from "@websensor/core";
5 import { db, sql } from "@websensor/db"; 5 import { db, sql } from "@websensor/db";
6 import { config } from "./config"; 6 import { config } from "./config";
7 7
8 /** 8 /**
9 * WebSocket gateway `/api/v1/live`. One Redis subscriber fans out to every client; clients 9 * WebSocket gateway `/api/v1/live`. One Redis subscriber fans out to every client; clients
10− * pick channels: events:global · events:breaking · events:<ai|cyber|finance|health|government| 10+ * pick channels: events:global · events:breaking · events:silent · events:first-party ·
11− * science|products|infrastructure> · entity:<id> · source:<id> · watchlist:<id>. 11+ * events:<ai|cyber|finance|health|government|science|products|infrastructure|news> · group:<security|…> ·
12− * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1} 12+ * country:<CA> · type:<event_type> · entity:<id> · source:<id> · watchlist:<id>.
13− * server → {"type":"hello"} | {"type":"event", "channels":[…], "event":{…}} | {"type":"pong"} 13+ * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1} | {"since":"<sid>"}
14+ * server → {"type":"hello"} | {"type":"event", "sid":"…", "channels":[…], "event":{…}} | {"type":"replay_done"} | {"type":"pong"} | {"type":"heartbeat"}
15+ * Every event frame carries the Redis stream id `sid`; after a reconnection the client sends
16+ * {"since": lastSid} and missed events (up to 500) are replayed from the durable stream (spec §60).
14 */ 17 */
15 interface Client { 18 interface Client {
16 ws: WebSocket; 19 ws: WebSocket;
17 channels: Set<string>; 20 channels: Set<string>;
18− watchlists: Map<string, { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }>; 21+ watchlists: Map<string, WatchRules>;
22+}
23+interface WatchRules {
24+ entities: Set<string>;
25+ sources: Set<string>;
26+ keywords: string[];
27+ categories: Set<string>;
28+ types: Set<string>;
29+ countries: Set<string>;
30+ urls: string[];
19 } 31 }
20 32
21 const clients = new Set<Client>(); 33 const clients = new Set<Client>();
22 let sub: Redis | null = null; 34 let sub: Redis | null = null;
35+let cmd: Redis | null = null;
23 let published = 0; 36 let published = 0;
24 37
25 export function liveStats(): { clients: number; published: number } { 38 export function liveStats(): { clients: number; published: number } {
26 return { clients: clients.size, published }; 39 return { clients: clients.size, published };
27 } 40 }
28 41
42+/** Engine heartbeat written by the scheduler (`ws:engine:status`). */
43+export async function engineStatus(): Promise<Record<string, unknown> | null> {
44+ try {
45+ const raw = await getCmd().get("ws:engine:status");
46+ return raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
47+ } catch {
48+ return null;
49+ }
50+}
51+
52+function getCmd(): Redis {
53+ if (!cmd) cmd = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, lazyConnect: false });
54+ return cmd;
55+}
56+
29 export async function registerLive(app: FastifyInstance): Promise<void> { 57 export async function registerLive(app: FastifyInstance): Promise<void> {
30 sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 }); 58 sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 });
31 sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error")); 59 sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error"));
60+ getCmd().on("error", (e) => app.log.warn({ err: e.message }, "redis cmd error"));
32 await sub.subscribe("ws:live"); 61 await sub.subscribe("ws:live");
33 sub.on("message", (_ch, msg) => { 62 sub.on("message", (_ch, msg) => {
34 let ev: Record<string, unknown>; 63 let ev: Record<string, unknown>;
@@ -38,21 +67,15 @@ export async function registerLive(app: FastifyInstance): Promise<void> {
38 return; 67 return;
39 } 68 }
40 published++; 69 published++;
41− const chans = channelsFor(ev); 70+ deliver(ev, String(ev.sid ?? ""));
42− for (const c of clients) {
43− const hit = [...chans].filter((ch) => c.channels.has(ch));
44− for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`);
45− if (!hit.length) continue;
46− if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", channels: hit, event: ev }));
47− }
48 }); 71 });
49 72
50 app.get("/api/v1/live", { websocket: true }, (socket) => { 73 app.get("/api/v1/live", { websocket: true }, (socket) => {
51 const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() }; 74 const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() };
52 clients.add(client); 75 clients.add(client);
53− socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString() })); 76+ socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString(), protocol: 2 }));
54 socket.on("message", async (raw: Buffer | string) => { 77 socket.on("message", async (raw: Buffer | string) => {
55− let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number }; 78+ let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number; since?: string };
56 try { 79 try {
57 msg = JSON.parse(raw.toString()) as typeof msg; 80 msg = JSON.parse(raw.toString()) as typeof msg;
58 } catch { 81 } catch {
@@ -68,7 +91,8 @@ export async function registerLive(app: FastifyInstance): Promise<void> {
68 client.channels.delete(ch); 91 client.channels.delete(ch);
69 if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10)); 92 if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10));
70 } 93 }
71− socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] })); 94+ if (msg.subscribe || msg.unsubscribe) socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] }));
95+ if (typeof msg.since === "string" && /^\d{10,16}-\d{1,6}$/.test(msg.since)) await replay(client, msg.since);
72 }); 96 });
73 const hb = setInterval(() => { 97 const hb = setInterval(() => {
74 if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() })); 98 if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() }));
@@ -84,37 +108,83 @@ export async function registerLive(app: FastifyInstance): Promise<void> {
84 }); 108 });
85 } 109 }
86 110
111+function deliver(ev: Record<string, unknown>, sid: string, only?: Client): void {
112+ const chans = channelsFor(ev);
113+ for (const c of only ? [only] : clients) {
114+ const hit = [...chans].filter((ch) => c.channels.has(ch));
115+ for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`);
116+ if (!hit.length) continue;
117+ if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", sid, channels: hit, event: ev }));
118+ }
119+}
120+
121+/** Replay missed events from the durable stream (exclusive of `since`). */
122+async function replay(client: Client, since: string): Promise<void> {
123+ try {
124+ const [ms, seq] = since.split("-");
125+ const start = `${ms}-${Number(seq) + 1}`;
126+ const rows = (await getCmd().xrange("ws:events", start, "+", "COUNT", 500)) as [string, string[]][];
127+ let n = 0;
128+ for (const [sid, fields] of rows) {
129+ const i = fields.indexOf("event");
130+ if (i < 0) continue;
131+ try {
132+ const ev = JSON.parse(fields[i + 1]!) as Record<string, unknown>;
133+ deliver({ ...ev, sid, replayed: true }, sid, client);
134+ n++;
135+ } catch {
136+ // skip malformed
137+ }
138+ }
139+ if (client.ws.readyState === client.ws.OPEN) client.ws.send(JSON.stringify({ type: "replay_done", since, count: n, truncated: rows.length >= 500 }));
140+ } catch {
141+ if (client.ws.readyState === client.ws.OPEN) client.ws.send(JSON.stringify({ type: "replay_done", since, count: 0, error: "replay_unavailable" }));
142+ }
143+}
144+
87 export function channelsFor(ev: Record<string, unknown>): Set<string> { 145 export function channelsFor(ev: Record<string, unknown>): Set<string> {
88 const out = new Set<string>(["events:global"]); 146 const out = new Set<string>(["events:global"]);
89 const importance = Number(ev.importance ?? 0); 147 const importance = Number(ev.importance ?? 0);
90− if (importance >= 80) out.add("events:breaking"); 148+ const signal = Number(ev.signal ?? importance);
149+ if (signal >= 80 || importance >= 80) out.add("events:breaking");
91 if (ev.silent) out.add("events:silent"); 150 if (ev.silent) out.add("events:silent");
151+ if (ev.firstParty !== false) out.add("events:first-party");
92 const cats = (ev.categories as string[] | undefined) ?? []; 152 const cats = (ev.categories as string[] | undefined) ?? [];
93 for (const [ch, list] of Object.entries(FEED_CHANNELS)) if (list.some((c) => cats.includes(c)) || cats.includes(ch)) out.add(`events:${ch}`); 153 for (const [ch, list] of Object.entries(FEED_CHANNELS)) if (list.some((c) => cats.includes(c)) || cats.includes(ch)) out.add(`events:${ch}`);
94 const src = ev.source as { id?: string } | undefined; 154 const src = ev.source as { id?: string } | undefined;
95 if (src?.id) out.add(`source:${src.id}`); 155 if (src?.id) out.add(`source:${src.id}`);
96 for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`); 156 for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`);
97 out.add(`type:${String(ev.type)}`); 157 out.add(`type:${String(ev.type)}`);
158+ out.add(`group:${String(ev.group ?? eventGroupOf(String(ev.type)))}`);
159+ if (ev.country) out.add(`country:${String(ev.country).toUpperCase()}`);
160+ if (ev.clusterState === "breaking" || ev.clusterState === "developing") out.add(`state:${String(ev.clusterState)}`);
98 return out; 161 return out;
99 } 162 }
100 163
101 async function loadWatchlist(client: Client, id: string): Promise<void> { 164 async function loadWatchlist(client: Client, id: string): Promise<void> {
102 const rows = await db.execute<{ kind: string; value: string }>(sql`select kind, value from watchlist_items where watchlist_id = ${id}`); 165 const rows = await db.execute<{ kind: string; value: string }>(sql`select kind, value from watchlist_items where watchlist_id = ${id}`);
103− const w = { entities: new Set<string>(), sources: new Set<string>(), keywords: [] as string[], categories: new Set<string>() }; 166+ const w: WatchRules = { entities: new Set(), sources: new Set(), keywords: [], categories: new Set(), types: new Set(), countries: new Set(), urls: [] };
104 for (const r of rows.rows) { 167 for (const r of rows.rows) {
105 if (r.kind === "entity") w.entities.add(r.value); 168 if (r.kind === "entity") w.entities.add(r.value);
106 else if (r.kind === "source") w.sources.add(r.value); 169 else if (r.kind === "source") w.sources.add(r.value);
107 else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase()); 170 else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase());
108 else if (r.kind === "category") w.categories.add(r.value); 171 else if (r.kind === "category") w.categories.add(r.value);
172+ else if (r.kind === "event_type") w.types.add(r.value);
173+ else if (r.kind === "country") w.countries.add(r.value.toUpperCase());
174+ else if (r.kind === "url") w.urls.push(r.value.replace(/\/$/, ""));
109 } 175 }
110 client.watchlists.set(id, w); 176 client.watchlists.set(id, w);
111 } 177 }
112 178
113−function matchesWatchlist(ev: Record<string, unknown>, w: { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }): boolean { 179+function matchesWatchlist(ev: Record<string, unknown>, w: WatchRules): boolean {
114 const src = ev.source as { id?: string } | undefined; 180 const src = ev.source as { id?: string } | undefined;
115 if (src?.id && w.sources.has(src.id)) return true; 181 if (src?.id && w.sources.has(src.id)) return true;
116 for (const e of (ev.entities as { id: string }[] | undefined) ?? []) if (w.entities.has(e.id)) return true; 182 for (const e of (ev.entities as { id: string }[] | undefined) ?? []) if (w.entities.has(e.id)) return true;
117 for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true; 183 for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true;
184+ if (w.types.has(String(ev.type))) return true;
185+ if (ev.country && w.countries.has(String(ev.country).toUpperCase())) return true;
186+ const url = String(ev.url ?? "");
187+ if (w.urls.some((u) => url === u || url.startsWith(u + "/"))) return true;
118 if (w.keywords.length) { 188 if (w.keywords.length) {
119 const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase(); 189 const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase();
120 if (w.keywords.some((k) => hay.includes(k))) return true; 190 if (w.keywords.some((k) => hay.includes(k))) return true;
@@ -126,4 +196,5 @@ export async function closeLive(): Promise<void> {
126 for (const c of clients) c.ws.close(1001, "server shutdown"); 196 for (const c of clients) c.ws.close(1001, "server shutdown");
127 clients.clear(); 197 clients.clear();
128 if (sub) await sub.quit().catch(() => undefined); 198 if (sub) await sub.quit().catch(() => undefined);
199+ if (cmd) await cmd.quit().catch(() => undefined);
129 } 200 }
modified apps/api/src/queries.ts +124 −23
@@ -1,3 +1,4 @@
1+import { EVENT_GROUPS, parseSearch } from "@websensor/core";
1 import { db, sql, textArray } from "@websensor/db"; 2 import { db, sql, textArray } from "@websensor/db";
2 3
3 /** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */ 4 /** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */
@@ -13,27 +14,48 @@ export interface EventFilters {
13 cluster?: string; 14 cluster?: string;
14 importance_min?: number; 15 importance_min?: number;
15 confidence_min?: number; 16 confidence_min?: number;
17+ signal_min?: number;
16 event_type?: string; 18 event_type?: string;
19+ group?: string;
17 silent_change?: boolean; 20 silent_change?: boolean;
21+ first_party?: boolean;
22+ confirmed?: boolean;
23+ country?: string;
24+ language?: string;
25+ change_class?: string;
18 q?: string; 26 q?: string;
19 limit: number; 27 limit: number;
20 cursor?: string; 28 cursor?: string;
21− order?: "recent" | "importance"; 29+ order?: "recent" | "importance" | "signal";
30+ /** include events from custom (owner) sources — only set by owner-scoped routes */
31+ includeCustom?: boolean;
22 } 32 }
23 33
24 export const EVENT_SELECT = sql` 34 export const EVENT_SELECT = sql`
25 e.id, e.slug, e.event_type, e.title, e.summary, e.why_it_matters, e.importance, e.confidence, e.novelty, e.categories, e.keywords, 35 e.id, e.slug, e.event_type, e.title, e.summary, e.why_it_matters, e.importance, e.confidence, e.novelty, e.categories, e.keywords,
26− e.silent_change, e.evidence_label, e.url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at, 36+ e.silent_change, e.evidence_label, e.url, e.canonical_url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at,
27 e.detection_latency_ms, e.processing_latency_ms, e.cluster_id, e.sensor_id, e.source_id, e.change_id, e.old_snapshot_id, e.new_snapshot_id, 37 e.detection_latency_ms, e.processing_latency_ms, e.cluster_id, e.sensor_id, e.source_id, e.change_id, e.old_snapshot_id, e.new_snapshot_id,
28 e.importance_components, e.processing_version, 38 e.importance_components, e.processing_version,
29− json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories) as source, 39+ e.signal_score, e.velocity_score, e.impact_score, e.anomaly_score, e.change_class, e.first_party, e.country, e.language, e.field_changes, e.score_reasons,
40+ json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories, 'first_party', s.first_party, 'country', s.country) as source,
30 json_build_object('id', sen.id, 'name', sen.name, 'type', sen.type, 'connector', sen.connector, 'tier', sen.tier) as sensor, 41 json_build_object('id', sen.id, 'name', sen.name, 'type', sen.type, 'connector', sen.connector, 'tier', sen.tier) as sensor,
31 coalesce((select json_agg(json_build_object('id', en.id, 'name', en.name, 'type', en.type, 'role', ee.role) order by ee.role, en.name) 42 coalesce((select json_agg(json_build_object('id', en.id, 'name', en.name, 'type', en.type, 'role', ee.role) order by ee.role, en.name)
32 from event_entities ee join entities en on en.id = ee.entity_id where ee.event_id = e.id), '[]'::json) as entities, 43 from event_entities ee join entities en on en.id = ee.entity_id where ee.event_id = e.id), '[]'::json) as entities,
33− (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size`; 44+ (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size,
45+ (select json_build_object('id', c.id, 'slug', c.slug, 'state', c.state, 'event_count', c.event_count, 'source_count', c.source_count, 'first_party_count', c.first_party_count, 'external_count', c.external_count, 'velocity', c.velocity, 'lead_time_ms', c.lead_time_ms) from event_clusters c where c.id = e.cluster_id) as cluster`;
34 46
35−export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> { 47+/** Build WHERE conditions from filters (shared by list, RSS, watchlist and desk queries). */
48+export function eventConditions(f: EventFilters): ReturnType<typeof sql>[] {
36 const conds = [sql`true`]; 49 const conds = [sql`true`];
50+ let free = f.q?.trim() ?? "";
51+ // Advanced syntax inside q (entity:… type:… after:…) is merged into the filters.
52+ if (free) {
53+ const p = parseSearch(free);
54+ free = p.text;
55+ const pf = p.filters;
56+ f = { ...f, entity: f.entity ?? pf.entity, source: f.source ?? pf.source, domain: f.domain ?? pf.domain, event_type: f.event_type ?? pf.event_type, group: f.group ?? pf.group, category: f.category ?? pf.category, country: f.country ?? pf.country, language: f.language ?? pf.language, after: f.after ?? pf.after, before: f.before ?? pf.before, silent_change: f.silent_change ?? pf.silent_change, first_party: f.first_party ?? pf.first_party, confirmed: f.confirmed ?? pf.confirmed, importance_min: f.importance_min ?? pf.importance_min, confidence_min: f.confidence_min ?? pf.confidence_min, signal_min: f.signal_min ?? pf.signal_min, change_class: f.change_class ?? pf.change_class, cluster: f.cluster ?? pf.cluster, sensor: f.sensor ?? pf.sensor };
57+ }
58+ if (!f.includeCustom) conds.push(sql`s.kind = 'registry'`);
37 if (f.after) conds.push(sql`e.detected_at > ${new Date(f.after)}`); 59 if (f.after) conds.push(sql`e.detected_at > ${new Date(f.after)}`);
38 if (f.before) conds.push(sql`e.detected_at < ${new Date(f.before)}`); 60 if (f.before) conds.push(sql`e.detected_at < ${new Date(f.before)}`);
39 if (f.category) conds.push(sql`${f.category} = any(e.categories)`); 61 if (f.category) conds.push(sql`${f.category} = any(e.categories)`);
@@ -44,22 +66,41 @@ export async function listEvents(f: EventFilters): Promise<{ items: Record<strin
44 if (f.entity) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = ${f.entity})`); 66 if (f.entity) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = ${f.entity})`);
45 if (f.importance_min !== undefined) conds.push(sql`e.importance >= ${f.importance_min}`); 67 if (f.importance_min !== undefined) conds.push(sql`e.importance >= ${f.importance_min}`);
46 if (f.confidence_min !== undefined) conds.push(sql`e.confidence >= ${f.confidence_min}`); 68 if (f.confidence_min !== undefined) conds.push(sql`e.confidence >= ${f.confidence_min}`);
47− if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(","))})`); 69+ if (f.signal_min !== undefined) conds.push(sql`coalesce(e.signal_score, e.importance) >= ${f.signal_min}`);
70+ if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(",").map((t) => t.trim()).filter(Boolean))})`);
71+ if (f.group && EVENT_GROUPS[f.group]) conds.push(sql`e.event_type = any(${textArray(EVENT_GROUPS[f.group]!.types)})`);
48 if (f.silent_change !== undefined) conds.push(sql`e.silent_change = ${f.silent_change}`); 72 if (f.silent_change !== undefined) conds.push(sql`e.silent_change = ${f.silent_change}`);
49− if (f.q) conds.push(sql`e.search @@ websearch_to_tsquery('english', ${f.q})`); 73+ if (f.first_party !== undefined) conds.push(sql`e.first_party = ${f.first_party}`);
74+ if (f.confirmed) conds.push(sql`e.evidence_label = 'CONFIRMED'`);
75+ if (f.country) conds.push(sql`e.country = ${f.country.toUpperCase()}`);
76+ if (f.language) conds.push(sql`e.language = ${f.language.toLowerCase()}`);
77+ if (f.change_class) conds.push(sql`e.change_class = ${f.change_class}`);
78+ if (free) conds.push(sql`(e.search @@ websearch_to_tsquery('english', ${free}) or e.title ilike ${"%" + free + "%"})`);
79+ return conds;
80+}
81+
82+export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> {
83+ const conds = eventConditions(f);
50 if (f.cursor) { 84 if (f.cursor) {
51 const [ts, id] = decodeCursor(f.cursor); 85 const [ts, id] = decodeCursor(f.cursor);
52 if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`); 86 if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`);
87+ else if (f.order === "signal") conds.push(sql`(coalesce(e.signal_score, e.importance), e.id) < (${Number(ts)}, ${id})`);
53 else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`); 88 else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`);
54 } 89 }
55− const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : sql`e.detected_at desc, e.id desc`; 90+ const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : f.order === "signal" ? sql`coalesce(e.signal_score, e.importance) desc, e.id desc` : sql`e.detected_at desc, e.id desc`;
56 const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` and `)} order by ${order} limit ${f.limit + 1}`); 91 const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` and `)} order by ${order} limit ${f.limit + 1}`);
57 const items = rows.rows.slice(0, f.limit); 92 const items = rows.rows.slice(0, f.limit);
58 const last = items[items.length - 1]; 93 const last = items[items.length - 1];
59− const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null; 94+ const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : f.order === "signal" ? String(last.signal_score ?? last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null;
60 return { items, nextCursor }; 95 return { items, nextCursor };
61 } 96 }
62 97
98+export async function countEvents(f: EventFilters): Promise<number> {
99+ const conds = eventConditions(f);
100+ const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from events e join sources s on s.id = e.source_id where ${sql.join(conds, sql` and `)}`);
101+ return Number(r.rows[0]?.n ?? 0);
102+}
103+
63 export async function getEvent(idOrSlug: string): Promise<Record<string, unknown> | null> { 104 export async function getEvent(idOrSlug: string): Promise<Record<string, unknown> | null> {
64 const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT}, e.interpretation from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = ${idOrSlug} or e.slug = ${idOrSlug} limit 1`); 105 const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT}, e.interpretation from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = ${idOrSlug} or e.slug = ${idOrSlug} limit 1`);
65 return rows.rows[0] ?? null; 106 return rows.rows[0] ?? null;
@@ -68,49 +109,78 @@ export async function getEvent(idOrSlug: string): Promise<Record<string, unknown
68 export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> { 109 export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> {
69 const rows = await db.execute<Record<string, unknown>>(sql` 110 const rows = await db.execute<Record<string, unknown>>(sql`
70 select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id 111 select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id
71− where e.id <> ${String(ev.id)} and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)})) 112+ where e.id <> ${String(ev.id)} and s.kind = 'registry' and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)}))
72 order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`); 113 order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`);
73 return rows.rows; 114 return rows.rows;
74 } 115 }
75 116
117+/** Previous meaningful events on the same sensor (historical context for "what changed" — spec §79–80). */
118+export async function sensorHistory(sensorId: string, beforeEventId: string, limit = 6): Promise<Record<string, unknown>[]> {
119+ const rows = await db.execute<Record<string, unknown>>(sql`
120+ select e.id, e.slug, e.title, e.event_type, e.importance, e.silent_change, e.detected_at, e.field_changes from events e
121+ where e.sensor_id = ${sensorId} and e.id <> ${beforeEventId} order by e.detected_at desc limit ${limit}`);
122+ return rows.rows;
123+}
124+
76 export async function stats(): Promise<Record<string, unknown>> { 125 export async function stats(): Promise<Record<string, unknown>> {
77 const [r] = ( 126 const [r] = (
78 await db.execute<Record<string, unknown>>(sql` 127 await db.execute<Record<string, unknown>>(sql`
79 select 128 select
80− (select count(*) from sources where enabled) as sources, 129+ (select count(*) from sources where enabled and kind = 'registry') as sources,
81 (select count(*) from sensors where enabled) as sensors, 130 (select count(*) from sensors where enabled) as sensors,
82 (select count(*) from entities) as entities, 131 (select count(*) from entities) as entities,
83 (select count(*) from snapshots) as snapshots, 132 (select count(*) from snapshots) as snapshots,
84 (select count(*) from events) as events_total, 133 (select count(*) from events) as events_total,
85 (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h, 134 (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h,
135+ (select count(*) from events where detected_at >= now() - interval '1 hour') as events_1h,
86 (select count(*) from events where detected_at >= now() - interval '24 hours' and silent_change) as silent_24h, 136 (select count(*) from events where detected_at >= now() - interval '24 hours' and silent_change) as silent_24h,
87− (select count(*) from events where detected_at >= now() - interval '24 hours' and importance >= 80) as breaking_24h, 137+ (select count(*) from events where detected_at >= now() - interval '24 hours' and coalesce(signal_score, importance) >= 80) as breaking_24h,
138+ (select count(*) from event_clusters where state = 'breaking') as breaking_now,
139+ (select count(*) from event_clusters where state = 'developing') as developing_now,
88 (select count(*) from changes where detected_at >= now() - interval '24 hours') as changes_24h, 140 (select count(*) from changes where detected_at >= now() - interval '24 hours') as changes_24h,
89− (select coalesce(sum(checks),0) from metrics_daily where day = current_date) as checks_today, 141+ (select coalesce(sum(checks),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as checks_today,
90− (select coalesce(sum(not_modified),0) from metrics_daily where day = current_date) as not_modified_today, 142+ (select coalesce(sum(not_modified),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as not_modified_today,
91− (select coalesce(sum(bytes),0) from metrics_daily where day = current_date) as bytes_today, 143+ (select coalesce(sum(bytes),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as bytes_today,
92 (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour, 144 (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour,
145+ (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes') as checks_last_5m,
146+ (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes' and outcome = 'not_modified') as not_modified_last_5m,
93 (select count(*) from sensors where health = 'UP' and enabled) as sensors_up, 147 (select count(*) from sensors where health = 'UP' and enabled) as sensors_up,
94 (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded, 148 (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded,
149+ (select count(*) from sources where enabled and first_party and kind = 'registry') as sources_first_party,
150+ (select count(distinct country) from sources where country is not null) as countries,
95 (select max(started_at) from sensor_runs) as last_check_at, 151 (select max(started_at) from sensor_runs) as last_check_at,
96 (select max(detected_at) from events) as last_event_at, 152 (select max(detected_at) from events) as last_event_at,
97 (select percentile_cont(0.5) within group (order by processing_latency_ms) from events where detected_at >= now() - interval '24 hours') as p50_processing_ms, 153 (select percentile_cont(0.5) within group (order by processing_latency_ms) from events where detected_at >= now() - interval '24 hours') as p50_processing_ms,
98 (select percentile_cont(0.5) within group (order by detection_latency_ms) from events where detected_at >= now() - interval '24 hours' and detection_latency_ms is not null and detection_latency_ms < 86400000) as p50_detection_ms`) 154 (select percentile_cont(0.5) within group (order by detection_latency_ms) from events where detected_at >= now() - interval '24 hours' and detection_latency_ms is not null and detection_latency_ms < 86400000) as p50_detection_ms`)
99 ).rows; 155 ).rows;
100− return Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v])); 156+ const out = Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+(\.\d+)?$/.test(v) ? Number(v) : v]));
157+ out.checks_per_min = Math.round(Number(out.checks_last_5m ?? 0) / 5);
158+ out.events_per_min = Math.round((Number(out.events_1h ?? 0) / 60) * 10) / 10;
159+ out.not_modified_ratio_5m = Number(out.checks_last_5m) ? Math.round((Number(out.not_modified_last_5m) / Number(out.checks_last_5m)) * 100) / 100 : null;
160+ return out;
101 } 161 }
102 162
103 export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> { 163 export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> {
104 const rows = await db.execute<Record<string, unknown>>(sql` 164 const rows = await db.execute<Record<string, unknown>>(sql`
105 with cur as ( 165 with cur as (
106− select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp 166+ select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp,
107− from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id), 167+ sum(case when e.first_party then 1 else 0 end) as first_party, sum(case when e.evidence_label = 'CONFIRMED' then 1 else 0 end) as confirmed, avg(coalesce(e.signal_score, e.importance)) as avg_signal,
168+ count(*) filter (where e.detected_at >= now() - make_interval(hours => ${hours}) / 4) as recent_quarter
169+ from events e join event_entities ee on ee.event_id = e.id join sources s on s.id = e.source_id where s.kind = 'registry' and e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id),
108 prev as ( 170 prev as (
109 select ee.entity_id, count(*) as n from events e join event_entities ee on ee.event_id = e.id 171 select ee.entity_id, count(*) as n from events e join event_entities ee on ee.event_id = e.id
110− where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id) 172+ where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id),
111− select en.id, en.name, en.type, en.domain, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance, coalesce(prev.n,0)::int as prev_events, 173+ base as (
112− round((18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent))::numeric, 1)::float as score 174+ select entity_id, avg(events)::float as per_day from entity_daily where day >= (now() at time zone 'UTC')::date - 30 and day < (now() at time zone 'UTC')::date group by entity_id)
113− from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id 175+ select en.id, en.name, en.type, en.domain, en.importance as entity_importance, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance,
176+ cur.first_party::int as first_party, cur.confirmed::int as confirmed, round(cur.avg_signal::numeric, 1)::float as avg_signal, coalesce(prev.n,0)::int as prev_events, coalesce(base.per_day, 0)::float as baseline_per_day,
177+ cur.recent_quarter::int as recent_quarter,
178+ case when cur.recent_quarter::float / greatest(1, cur.n) > 0.5 then 'up' when cur.recent_quarter = 0 and cur.n >= 3 then 'down' else 'flat' end as direction,
179+ round((100 * (1 - exp(-(
180+ 18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent)
181+ + 6*least(3, cur.first_party) + 4*least(3, cur.confirmed) + (case when coalesce(base.per_day,0) > 0 then 8*least(3, cur.n / (base.per_day * ${hours} / 24.0)) else 0 end)
182+ ) / 110.0)))::numeric, 1)::float as score
183+ from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id left join base on base.entity_id = cur.entity_id
114 order by score desc limit ${limit}`); 184 order by score desc limit ${limit}`);
115 return rows.rows; 185 return rows.rows;
116 } 186 }
@@ -122,7 +192,9 @@ export async function sourceActivity(sourceId: string): Promise<Record<string, u
122 (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::int as changes_2h, 192 (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::int as changes_2h,
123 (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::int as changes_14d, 193 (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::int as changes_14d,
124 (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours')::int as events_24h, 194 (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours')::int as events_24h,
125− (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d`) 195+ (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d,
196+ (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours' and silent_change)::int as silent_24h,
197+ (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours' and coalesce(signal_score, importance) >= 80)::int as breaking_24h`)
126 ).rows; 198 ).rows;
127 const c2h = Number(r?.changes_2h ?? 0); 199 const c2h = Number(r?.changes_2h ?? 0);
128 const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24); 200 const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24);
@@ -136,6 +208,35 @@ export async function sourceActivity(sourceId: string): Promise<Record<string, u
136 return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 }; 208 return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 };
137 } 209 }
138 210
211+/** Source quality score (spec §48): success rate, latency, structured share, usefulness (events/raw), consistency. Distinct from importance. */
212+export async function sourceQuality(sourceId: string): Promise<Record<string, unknown>> {
213+ const [r] = (
214+ await db.execute<Record<string, unknown>>(sql`
215+ select
216+ (select count(*) from sensors where source_id = ${sourceId} and enabled)::int as sensors,
217+ (select count(*) from sensors where source_id = ${sourceId} and enabled and health = 'UP')::int as sensors_up,
218+ (select avg(avg_latency_ms) from sensors where source_id = ${sourceId} and enabled)::int as avg_latency_ms,
219+ (select coalesce(sum(raw_changes),0) from sensors where source_id = ${sourceId})::int as raw_changes,
220+ (select coalesce(sum(meaningful_changes),0) from sensors where source_id = ${sourceId})::int as meaningful_changes,
221+ (select coalesce(sum(total_runs),0) from sensors where source_id = ${sourceId})::int as total_runs,
222+ (select coalesce(sum(total_not_modified),0) from sensors where source_id = ${sourceId})::int as total_not_modified,
223+ (select count(*) from sensors where source_id = ${sourceId} and enabled and connector <> 'http')::int as structured_sensors,
224+ (select coalesce(sum(checks),0) from source_daily where source_id = ${sourceId} and day >= (now() at time zone 'UTC')::date - 7)::int as checks_7d,
225+ (select coalesce(sum(errors),0) from source_daily where source_id = ${sourceId} and day >= (now() at time zone 'UTC')::date - 7)::int as errors_7d,
226+ (select avg(confidence) from events where source_id = ${sourceId} and detected_at >= now() - interval '30 days')::float as avg_confidence`)
227+ ).rows;
228+ const sensors = Number(r?.sensors ?? 0);
229+ const success = Number(r?.checks_7d) ? 1 - Number(r?.errors_7d) / Number(r?.checks_7d) : sensors ? Number(r?.sensors_up) / sensors : 1;
230+ const latency = Number(r?.avg_latency_ms ?? 800);
231+ const latencyScore = latency <= 400 ? 1 : latency <= 1500 ? 0.8 : latency <= 4000 ? 0.6 : 0.4;
232+ const structured = sensors ? Number(r?.structured_sensors) / sensors : 0;
233+ const raw = Number(r?.raw_changes ?? 0);
234+ const usefulness = raw >= 5 ? Math.min(1, (Number(r?.meaningful_changes) / raw) * 2) : 0.6;
235+ const conf = Number(r?.avg_confidence ?? 70) / 100;
236+ const score = Math.round(100 * (0.35 * success + 0.15 * latencyScore + 0.15 * structured + 0.2 * usefulness + 0.15 * conf));
237+ return { ...r, success_rate: Math.round(success * 1000) / 1000, structured_share: Math.round(structured * 100) / 100, usefulness: Math.round(usefulness * 100) / 100, quality_score: score };
238+}
239+
139 export function encodeCursor(a: string, b: string): string { 240 export function encodeCursor(a: string, b: string): string {
140 return Buffer.from(`${a}|${b}`).toString("base64url"); 241 return Buffer.from(`${a}|${b}`).toString("base64url");
141 } 242 }
added apps/api/src/routes-admin.ts +228 −0
@@ -0,0 +1,228 @@
1+import { readdir, stat } from "node:fs/promises";
2+import { join, resolve } from "node:path";
3+import type { FastifyInstance } from "fastify";
4+import YAML from "yaml";
5+import { z } from "zod";
6+import { extendSchema, importDocumentSchema, sourceSchema, type SensorEndpoint, type Tier } from "@websensor/core";
7+import { getConnector, listConnectors, NormalizeError } from "@websensor/connectors";
8+import { db, sql } from "@websensor/db";
9+import { cacheStats, invalidate } from "./cache";
10+import { config } from "./config";
11+import { engineStatus, liveStats } from "./live";
12+
13+/**
14+ * Internal operations (spec §70–72, §85). Enabled only when WS_ADMIN_TOKEN is set; every request
15+ * must carry `X-WebSensor-Admin: <token>`. Never linked from public pages; `/ops` in the web app
16+ * asks for the token and stores it in sessionStorage.
17+ */
18+export async function registerAdminRoutes(app: FastifyInstance): Promise<void> {
19+ app.addHook("onRequest", async (req, reply) => {
20+ if (!req.url.startsWith("/api/v1/admin")) return;
21+ if (!config.adminToken) return reply.status(404).send({ error: "not_found" });
22+ const t = req.headers["x-websensor-admin"];
23+ if (typeof t !== "string" || t !== config.adminToken) return reply.status(401).send({ error: "admin_token_required" });
24+ });
25+
26+ app.get("/api/v1/admin/ops", async () => {
27+ const t0 = Date.now();
28+ await db.execute(sql`select 1`);
29+ const dbLatency = Date.now() - t0;
30+ const [queue, failing, slow, throughput, storage, llm, jobs, es, byStatus, recentErrors] = await Promise.all([
31+ db.execute<Record<string, unknown>>(sql`select count(*) filter (where next_check_at <= now())::int as due, count(*) filter (where next_check_at <= now() - interval '10 minutes')::int as overdue_10m, count(*) filter (where priority = 0)::int as p0, count(*) filter (where priority = 0 and next_check_at <= now())::int as p0_due, min(next_check_at) as oldest_due from sensors where enabled`).then((r) => r.rows[0]),
32+ db.execute<Record<string, unknown>>(sql`select split_part(split_part(s.url, '/', 3), ':', 1) as host, count(*)::int as failures, count(distinct s.id)::int as sensors, max(r.error) as last_error, max(r.started_at) as last_at from sensor_runs r join sensors s on s.id = r.sensor_id where r.started_at >= now() - interval '6 hours' and r.outcome in ('error','parse_error','rate_limited') group by 1 order by failures desc limit 20`).then((r) => r.rows),
33+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.connector, s.avg_latency_ms, s.total_runs from sensors s where s.enabled and s.avg_latency_ms is not null order by s.avg_latency_ms desc limit 20`).then((r) => r.rows),
34+ db.execute<Record<string, unknown>>(sql`
35+ select to_char(date_trunc('hour', started_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as t, count(*)::int as checks, count(*) filter (where outcome = 'not_modified')::int as not_modified, count(*) filter (where outcome in ('error','parse_error','rate_limited'))::int as errors, count(*) filter (where outcome = 'event')::int as events, avg(duration_ms)::int as avg_ms
36+ from sensor_runs where started_at >= now() - interval '24 hours' group by 1 order by 1`).then((r) => r.rows),
37+ Promise.all([
38+ db.execute<Record<string, unknown>>(sql`select pg_size_pretty(pg_database_size(current_database())) as db_size, pg_database_size(current_database())::bigint as db_bytes, (select count(*) from snapshots)::int as snapshots, (select count(*) from snapshots where storage_key is not null)::int as snapshots_with_raw, (select coalesce(sum(content_length),0) from snapshots where storage_key is not null)::bigint as raw_bytes_uncompressed, (select count(*) from changes)::int as changes, (select count(*) from events)::int as events, (select count(*) from sensor_runs)::int as runs`).then((r) => r.rows[0]),
39+ blobDirSize(),
40+ ]).then(([d, blobs]) => ({ ...d, blobs })),
41+ db.execute<Record<string, unknown>>(sql`select model, count(*)::int as calls, sum(input_tokens)::bigint as input_tokens, sum(output_tokens)::bigint as output_tokens, count(*) filter (where not ok)::int as failures from llm_usage where at >= now() - interval '24 hours' group by model`).then((r) => r.rows),
42+ db.execute<Record<string, unknown>>(sql`select outcome, count(*)::int as n from sensor_runs where started_at >= now() - interval '1 hour' group by outcome order by n desc`).then((r) => r.rows),
43+ engineStatus(),
44+ db.execute<Record<string, unknown>>(sql`select status, health, count(*)::int as n from sensors group by status, health order by n desc`).then((r) => r.rows),
45+ db.execute<Record<string, unknown>>(sql`select r.started_at, s.id as sensor_id, s.source_id, s.connector, r.http_status, r.outcome, r.error from sensor_runs r join sensors s on s.id = r.sensor_id where r.outcome in ('error','parse_error') and r.started_at >= now() - interval '1 hour' order by r.started_at desc limit 30`).then((r) => r.rows),
46+ ]);
47+ return { db: { latency_ms: dbLatency }, queue, engine: es, workers: es ? { inflight: es.inflight, concurrency: es.concurrency, busy_hosts: es.busyHosts, circuit_open: es.circuitOpen } : null, outcomes_1h: jobs, top_failing_domains: failing, slowest_sensors: slow, throughput_24h: throughput, storage, llm_24h: llm, sensors_by_status: byStatus, recent_errors: recentErrors, cache: cacheStats(), live: liveStats(), connectors: listConnectors().map((c) => c.metadata()), generated_at: new Date().toISOString() };
48+ });
49+
50+ // ---- Sensor actions ------------------------------------------------------------------------------
51+ app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/run-now", async (req, reply) => {
52+ const r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second', enabled = true where id = ${req.params.id}`);
53+ if (!r.rowCount) return reply.status(404).send({ error: "not_found" });
54+ return { ok: true, scheduled: "now" };
55+ });
56+ app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/enable", async (req, reply) => {
57+ const r = await db.execute(sql`update sensors set enabled = true, status = 'ACTIVE', consecutive_errors = 0, next_check_at = now(), updated_at = now() where id = ${req.params.id}`);
58+ if (!r.rowCount) return reply.status(404).send({ error: "not_found" });
59+ return { ok: true };
60+ });
61+ app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/disable", async (req, reply) => {
62+ const r = await db.execute(sql`update sensors set enabled = false, status = 'DISABLED', updated_at = now() where id = ${req.params.id}`);
63+ if (!r.rowCount) return reply.status(404).send({ error: "not_found" });
64+ return { ok: true };
65+ });
66+ app.patch<{ Params: { id: string } }>("/api/v1/admin/sensors/:id", async (req, reply) => {
67+ const body = z.object({ tier: z.enum(["S", "A", "B", "C", "D"]).optional(), priority: z.number().int().min(0).max(3).optional(), base_interval_seconds: z.number().int().min(15).max(7 * 86400).nullable().optional(), config: z.record(z.string(), z.unknown()).optional(), name: z.string().min(1).max(120).optional() }).parse(req.body ?? {});
68+ const sets: ReturnType<typeof sql>[] = [sql`updated_at = now()`];
69+ if (body.tier) sets.push(sql`tier = ${body.tier}`);
70+ if (body.priority !== undefined) sets.push(sql`priority = ${body.priority}`);
71+ if (body.base_interval_seconds !== undefined) sets.push(sql`base_interval_seconds = ${body.base_interval_seconds}`);
72+ if (body.config) sets.push(sql`config = config || ${JSON.stringify(body.config)}::jsonb`);
73+ if (body.name) sets.push(sql`name = ${body.name}`);
74+ const r = await db.execute(sql`update sensors set ${sql.join(sets, sql`, `)} where id = ${req.params.id}`);
75+ if (!r.rowCount) return reply.status(404).send({ error: "not_found" });
76+ return { ok: true };
77+ });
78+ /** Dry-run a connector against a URL (or an existing sensor) without persisting anything: inspect response + normalized output. */
79+ app.post("/api/v1/admin/sensors/test", async (req, reply) => {
80+ const body = z.object({ sensor_id: z.string().optional(), url: z.string().url().optional(), connector: z.string().default("http"), type: z.string().default("HTML"), config: z.record(z.string(), z.unknown()).default({}) }).parse(req.body ?? {});
81+ let endpoint: SensorEndpoint;
82+ if (body.sensor_id) {
83+ const s = (await db.execute<Record<string, unknown>>(sql`select * from sensors where id = ${body.sensor_id}`)).rows[0];
84+ if (!s) return reply.status(404).send({ error: "not_found" });
85+ endpoint = { id: String(s.id), sourceId: String(s.source_id), name: String(s.name), url: String(s.url), type: String(s.type) as SensorEndpoint["type"], tier: String(s.tier) as Tier, connector: String(s.connector), config: (s.config as Record<string, unknown>) ?? {}, etag: null, lastModified: null, state: null };
86+ } else if (body.url) {
87+ endpoint = { id: "admin_test", sourceId: "admin", name: "test", url: body.url, type: body.type as SensorEndpoint["type"], tier: "C", connector: body.connector, config: body.config, etag: null, lastModified: null, state: null };
88+ } else return reply.status(400).send({ error: "sensor_id_or_url_required" });
89+ let connector;
90+ try {
91+ connector = getConnector(endpoint.connector);
92+ } catch (e) {
93+ return reply.status(400).send({ error: "unknown_connector", detail: (e as Error).message });
94+ }
95+ const t0 = Date.now();
96+ const obs = await connector.fetch(endpoint);
97+ const fetchMs = Date.now() - t0;
98+ if (obs.error) return { ok: false, stage: "fetch", error: obs.error, meta: obs.meta, fetch_ms: fetchMs };
99+ if (obs.meta.status >= 400) return { ok: false, stage: "fetch", http_status: obs.meta.status, meta: obs.meta, fetch_ms: fetchMs };
100+ try {
101+ const norm = await connector.normalize(endpoint, obs);
102+ return { ok: true, fetch_ms: fetchMs, meta: obs.meta, normalized: { mode: norm.mode, title: norm.title ?? null, extractionConfidence: norm.extractionConfidence, items: norm.items?.length ?? null, sample_items: norm.items?.slice(0, 5) ?? null, text_preview: norm.text?.slice(0, 1500) ?? null, json_preview: norm.json !== undefined ? JSON.stringify(norm.json).slice(0, 1500) : null, canonicalHash: norm.canonicalHash, publishedAt: norm.publishedAt ?? null } };
103+ } catch (e) {
104+ return { ok: false, stage: "normalize", error: e instanceof NormalizeError ? { code: e.code, message: e.message } : { code: "error", message: (e as Error).message }, meta: obs.meta, fetch_ms: fetchMs, body_preview: obs.body?.toString("utf8").slice(0, 800) ?? null };
105+ }
106+ });
107+
108+ // ---- Source actions ------------------------------------------------------------------------------
109+ app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/enable", async (req) => {
110+ await db.execute(sql`update sources set enabled = true, updated_at = now() where id = ${req.params.id}`);
111+ invalidate("");
112+ return { ok: true };
113+ });
114+ app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/disable", async (req) => {
115+ await db.execute(sql`update sources set enabled = false, updated_at = now() where id = ${req.params.id}`);
116+ invalidate("");
117+ return { ok: true };
118+ });
119+ app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/run-now", async (req) => {
120+ const r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second' where source_id = ${req.params.id} and enabled`);
121+ return { ok: true, sensors: r.rowCount ?? 0 };
122+ });
123+ app.post("/api/v1/admin/sensors/bulk", async (req) => {
124+ const body = z.object({ ids: z.array(z.string()).min(1).max(2000), action: z.enum(["enable", "disable", "run-now"]) }).parse(req.body ?? {});
125+ const arr = sql.raw("array[" + body.ids.map((i) => "'" + i.replace(/'/g, "''") + "'").join(",") + "]::text[]");
126+ let r;
127+ if (body.action === "enable") r = await db.execute(sql`update sensors set enabled = true, status = 'ACTIVE', consecutive_errors = 0, next_check_at = now() where id = any(${arr})`);
128+ else if (body.action === "disable") r = await db.execute(sql`update sensors set enabled = false, status = 'DISABLED' where id = any(${arr})`);
129+ else r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second' where id = any(${arr}) and enabled`);
130+ return { ok: true, affected: r.rowCount ?? 0 };
131+ });
132+
133+ /**
134+ * Bulk import (spec §71): JSON body `{ sources: [...] }` or raw YAML (content-type text/yaml). Validates with the
135+ * registry schema first; `dry_run=1` returns the validation report without writing. Imported sources are stored
136+ * with `kind = 'registry'` and `config.seed = false` so the file-based sync never disables them.
137+ */
138+ app.post<{ Querystring: { dry_run?: string } }>("/api/v1/admin/sources/import", async (req, reply) => {
139+ let doc: unknown = req.body;
140+ if (typeof req.body === "string") {
141+ try {
142+ doc = YAML.parse(req.body);
143+ } catch (e) {
144+ return reply.status(400).send({ error: "yaml_parse_error", detail: (e as Error).message });
145+ }
146+ }
147+ const parsed = importDocumentSchema.safeParse(doc);
148+ if (!parsed.success) return reply.status(400).send({ error: "invalid_document", issues: parsed.error.issues });
149+ const report: { id: string | undefined; ok: boolean; extend: boolean; issues?: string[]; sensors?: number }[] = [];
150+ const existing = new Set((await db.execute<{ id: string }>(sql`select id from sources`)).rows.map((r) => r.id));
151+ const valid: { extend: boolean; data: z.infer<typeof sourceSchema> | z.infer<typeof extendSchema> }[] = [];
152+ for (const s of parsed.data.sources) {
153+ const isExtend = (s as { extend?: boolean })?.extend === true;
154+ const res = isExtend ? extendSchema.safeParse(s) : sourceSchema.safeParse(s);
155+ const id = (s as { id?: string })?.id;
156+ if (!res.success) {
157+ report.push({ id, ok: false, extend: isExtend, issues: res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) });
158+ continue;
159+ }
160+ if (isExtend && !existing.has(res.data.id)) {
161+ report.push({ id, ok: false, extend: true, issues: ["extend: true but source does not exist"] });
162+ continue;
163+ }
164+ if (!isExtend && existing.has(res.data.id)) {
165+ report.push({ id, ok: false, extend: false, issues: ["source id already exists (use extend: true)"] });
166+ continue;
167+ }
168+ valid.push({ extend: isExtend, data: res.data });
169+ report.push({ id, ok: true, extend: isExtend, sensors: res.data.sensors.length });
170+ }
171+ if (req.query.dry_run === "1") return { dry_run: true, valid: valid.length, invalid: report.filter((r) => !r.ok).length, report };
172+ let sensors = 0;
173+ for (const v of valid) {
174+ if (!v.extend) {
175+ const s = v.data as z.infer<typeof sourceSchema>;
176+ await db.execute(sql`insert into sources (id, name, domain, homepage, description, categories, tier, importance_weight, discover, fallback, notes, enabled, llm_enabled, first_party, country, language, kind)
177+ values (${s.id}, ${s.name}, ${s.domain}, ${s.homepage ?? `https://${s.domain}`}, ${s.description ?? null}, ${sql.raw("'{" + s.categories.map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}, ${s.tier}, ${s.weight}, ${JSON.stringify(s.discover)}::jsonb, ${JSON.stringify(s.fallback)}::jsonb, ${s.notes ?? null}, ${s.enabled}, ${s.llm}, ${s.first_party ?? true}, ${s.country ?? null}, ${s.language ?? null}, 'registry')`);
178+ const entId = `org_${s.id}`;
179+ await db.execute(sql`insert into entities (id, name, type, domain, homepage, description, importance, categories) values (${entId}, ${s.name}, ${s.entity_type}, ${s.domain}, ${s.homepage ?? `https://${s.domain}`}, ${s.description ?? null}, 60, ${sql.raw("'{" + s.categories.map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}) on conflict (id) do nothing`);
180+ await db.execute(sql`insert into source_entities (source_id, entity_id) values (${s.id}, ${entId}) on conflict do nothing`);
181+ for (const a of new Set([s.name, s.domain, ...s.aliases])) await db.execute(sql`insert into entity_aliases (alias, entity_id) values (${a.toLowerCase()}, ${entId}) on conflict do nothing`);
182+ }
183+ const base = v.data;
184+ for (const sen of base.sensors) {
185+ const id = sen.id ?? `${base.id}_${sen.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
186+ await db.execute(sql`insert into sensors (id, source_id, name, url, type, connector, tier, importance_weight, config, base_interval_seconds, status, priority)
187+ values (${id}, ${base.id}, ${sen.name}, ${sen.url}, ${sen.type}, ${sen.connector}, ${sen.tier ?? (v.extend ? "B" : (v.data as z.infer<typeof sourceSchema>).tier)}, ${sen.weight ?? 1}, ${JSON.stringify({ ...sen.config, seed: false, imported_at: new Date().toISOString() })}::jsonb, ${sen.interval ?? null}, 'PENDING', 2)
188+ on conflict (id) do update set name = excluded.name, url = excluded.url, type = excluded.type, connector = excluded.connector, config = excluded.config, enabled = true, updated_at = now()`);
189+ sensors++;
190+ }
191+ }
192+ invalidate("");
193+ return { dry_run: false, imported_sources: valid.filter((v) => !v.extend).length, extended_sources: valid.filter((v) => v.extend).length, sensors, invalid: report.filter((r) => !r.ok).length, report };
194+ });
195+
196+ app.get("/api/v1/admin/failures", async () => {
197+ const rows = await db.execute<Record<string, unknown>>(sql`select r.started_at, r.outcome, r.http_status, r.error, r.duration_ms, s.id as sensor_id, s.name, s.source_id, s.url, s.connector from sensor_runs r join sensors s on s.id = r.sensor_id where r.outcome in ('error','parse_error','rate_limited') and r.started_at >= now() - interval '24 hours' order by r.started_at desc limit 200`);
198+ return { items: rows.rows };
199+ });
200+}
201+
202+async function blobDirSize(): Promise<{ files: number; bytes: number } | null> {
203+ const root = resolve(process.env.BLOB_STORE_DIR ?? "./data/blobs");
204+ let files = 0;
205+ let bytes = 0;
206+ const started = Date.now();
207+ const walk = async (dir: string): Promise<void> => {
208+ if (Date.now() - started > 4000) return; // bounded
209+ let entries: string[];
210+ try {
211+ entries = await readdir(dir);
212+ } catch {
213+ return;
214+ }
215+ for (const e of entries) {
216+ const p = join(dir, e);
217+ const st = await stat(p).catch(() => null);
218+ if (!st) continue;
219+ if (st.isDirectory()) await walk(p);
220+ else {
221+ files++;
222+ bytes += st.size;
223+ }
224+ }
225+ };
226+ await walk(root);
227+ return { files, bytes };
228+}
added apps/api/src/routes-user.ts +299 −0
@@ -0,0 +1,299 @@
1+import { createHash } from "node:crypto";
2+import type { FastifyInstance } from "fastify";
3+import { z } from "zod";
4+import { assertUrlAllowed, newId, UrlPolicyError } from "@websensor/core";
5+import { getConnector } from "@websensor/connectors";
6+import { db, sql, textArray } from "@websensor/db";
7+import { config } from "./config";
8+import { EVENT_SELECT, eventConditions, listEvents } from "./queries";
9+
10+/**
11+ * Owner-scoped routes (anonymous owner token, phase 1): watchlists, alert rules (+ webhook
12+ * channel), notifications, bookmarks, saved views and custom URL monitors (spec §42–45, §81, §100).
13+ */
14+export function ownerToken(headers: Record<string, unknown>): string | null {
15+ const t = headers["x-websensor-owner"];
16+ return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null;
17+}
18+
19+const WATCH_KINDS = ["entity", "source", "keyword", "category", "url", "event_type", "country", "group"] as const;
20+
21+export async function registerUserRoutes(app: FastifyInstance): Promise<void> {
22+ const requireOwner = (req: { headers: Record<string, unknown> }, reply: { status: (n: number) => { send: (b: unknown) => unknown } }): string | null => {
23+ const owner = ownerToken(req.headers);
24+ if (!owner) reply.status(401).send({ error: "owner_token_required", detail: "Send an X-WebSensor-Owner header (16–80 URL-safe characters)." });
25+ return owner;
26+ };
27+
28+ // ---- Watchlists -----------------------------------------------------------------------------
29+ app.get("/api/v1/watchlists", async (req, reply) => {
30+ const owner = requireOwner(req, reply);
31+ if (!owner) return;
32+ const rows = await db.execute<Record<string, unknown>>(sql`select w.id, w.name, w.created_at, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at) order by i.added_at) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`);
33+ return { items: rows.rows };
34+ });
35+ const wlBody = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(WATCH_KINDS), value: z.string().min(1).max(300) })).max(300).default([]) });
36+ app.post("/api/v1/watchlists", async (req, reply) => {
37+ const owner = requireOwner(req, reply);
38+ if (!owner) return;
39+ const body = wlBody.parse(req.body ?? {});
40+ const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n;
41+ if (Number(count) >= 20) return reply.status(429).send({ error: "too_many_watchlists" });
42+ const id = newId("wl");
43+ await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`);
44+ for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`);
45+ return { id, name: body.name, items: body.items };
46+ });
47+ app.put<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => {
48+ const owner = requireOwner(req, reply);
49+ if (!owner) return;
50+ const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0];
51+ if (!w) return reply.status(404).send({ error: "not_found" });
52+ const body = wlBody.partial().parse(req.body ?? {});
53+ if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`);
54+ if (body.items) {
55+ await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`);
56+ for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`);
57+ }
58+ return { ok: true };
59+ });
60+ app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => {
61+ const owner = requireOwner(req, reply);
62+ if (!owner) return;
63+ await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`);
64+ return { ok: true };
65+ });
66+ app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => {
67+ const owner = requireOwner(req, reply);
68+ if (!owner) return;
69+ const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows;
70+ if (!items.length) return reply.send({ items: [] });
71+ const by = (k: string): string[] => items.filter((i) => i.kind === k).map((i) => i.value);
72+ const conds = [] as ReturnType<typeof sql>[];
73+ const ents = by("entity");
74+ const srcs = by("source");
75+ const cats = by("category");
76+ const types = by("event_type");
77+ const countries = by("country");
78+ const urls = by("url");
79+ if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`);
80+ if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`);
81+ if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`);
82+ if (types.length) conds.push(sql`e.event_type = any(${textArray(types)})`);
83+ if (countries.length) conds.push(sql`e.country = any(${textArray(countries.map((c) => c.toUpperCase()))})`);
84+ for (const u of urls) conds.push(sql`(e.url = ${u} or e.url like ${u.replace(/\/$/, "") + "/%"})`);
85+ for (const k of by("keyword")) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`);
86+ if (!conds.length) return { items: [] };
87+ const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where (s.kind = 'registry' or s.owner_token = ${owner}) and (${sql.join(conds, sql` or `)}) order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
88+ return { items: rows.rows };
89+ });
90+
91+ // ---- Alerts -----------------------------------------------------------------------------------
92+ const ruleSchema = z.object({
93+ importance_min: z.number().min(0).max(100).optional(),
94+ signal_min: z.number().min(0).max(100).optional(),
95+ event_types: z.array(z.string().max(60)).max(60).optional(),
96+ groups: z.array(z.string().max(30)).max(12).optional(),
97+ entities: z.array(z.string().max(120)).max(100).optional(),
98+ sources: z.array(z.string().max(120)).max(100).optional(),
99+ keywords: z.array(z.string().max(80)).max(50).optional(),
100+ categories: z.array(z.string().max(40)).max(40).optional(),
101+ countries: z.array(z.string().max(3)).max(60).optional(),
102+ silent_only: z.boolean().optional(),
103+ first_party_only: z.boolean().optional(),
104+ confirmed_only: z.boolean().optional(),
105+ });
106+ const alertBody = z.object({ name: z.string().min(1).max(80), rule: ruleSchema, channel: z.enum(["web", "webhook"]).default("web"), channel_config: z.object({ url: z.string().url().max(500).optional(), secret: z.string().max(200).optional() }).default({}) });
107+ app.get("/api/v1/alerts", async (req, reply) => {
108+ const owner = requireOwner(req, reply);
109+ if (!owner) return;
110+ const rows = await db.execute<Record<string, unknown>>(sql`select a.id, a.name, a.rule, a.channel, a.channel_config - 'secret' as channel_config, a.enabled, a.created_at, a.last_fired_at, a.fired_count, (select count(*) from notifications n where n.alert_id = a.id and n.created_at >= now() - interval '24 hours')::int as fired_24h from alerts a where owner_token = ${owner} order by created_at`);
111+ return { items: rows.rows, channels: { web: "active", webhook: "active", email: "planned", slack: "planned", discord: "planned", telegram: "planned", push: "planned" } };
112+ });
113+ app.post("/api/v1/alerts", async (req, reply) => {
114+ const owner = requireOwner(req, reply);
115+ if (!owner) return;
116+ const body = alertBody.parse(req.body ?? {});
117+ if (body.channel === "webhook") {
118+ if (!body.channel_config.url) return reply.status(400).send({ error: "webhook_url_required" });
119+ try {
120+ const u = await assertUrlAllowed(body.channel_config.url);
121+ if (u.url.protocol !== "https:") return reply.status(400).send({ error: "webhook_https_required" });
122+ } catch (e) {
123+ return reply.status(400).send({ error: "webhook_url_rejected", detail: e instanceof UrlPolicyError ? e.message : String(e) });
124+ }
125+ }
126+ const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from alerts where owner_token = ${owner}`)).rows[0]?.n;
127+ if (Number(count) >= 50) return reply.status(429).send({ error: "too_many_alerts" });
128+ const id = newId("alr");
129+ await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel, channel_config) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel}, ${JSON.stringify(body.channel_config)}::jsonb)`);
130+ return { id, name: body.name, rule: body.rule, channel: body.channel };
131+ });
132+ app.patch<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => {
133+ const owner = requireOwner(req, reply);
134+ if (!owner) return;
135+ const body = z.object({ enabled: z.boolean().optional(), name: z.string().min(1).max(80).optional() }).parse(req.body ?? {});
136+ if (body.enabled !== undefined) await db.execute(sql`update alerts set enabled = ${body.enabled} where id = ${req.params.id} and owner_token = ${owner}`);
137+ if (body.name) await db.execute(sql`update alerts set name = ${body.name} where id = ${req.params.id} and owner_token = ${owner}`);
138+ return { ok: true };
139+ });
140+ app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => {
141+ const owner = requireOwner(req, reply);
142+ if (!owner) return;
143+ await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`);
144+ return { ok: true };
145+ });
146+ app.get<{ Querystring: { limit?: string; unread?: string } }>("/api/v1/notifications", async (req, reply) => {
147+ const owner = requireOwner(req, reply);
148+ if (!owner) return;
149+ const rows = await db.execute<Record<string, unknown>>(sql`
150+ select n.id, n.alert_id, a.name as alert_name, n.event_id, n.channel, n.status, n.created_at, n.read_at, n.delivered_at, n.error,
151+ json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'signal_score', e.signal_score, 'event_type', e.event_type, 'silent_change', e.silent_change, 'detected_at', e.detected_at, 'source', json_build_object('id', s.id, 'name', s.name)) as event
152+ from notifications n join alerts a on a.id = n.alert_id join events e on e.id = n.event_id join sources s on s.id = e.source_id
153+ where a.owner_token = ${owner} ${req.query.unread === "1" ? sql`and n.read_at is null` : sql``} order by n.created_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
154+ const unread = (await db.execute<{ n: string }>(sql`select count(*)::text as n from notifications n join alerts a on a.id = n.alert_id where a.owner_token = ${owner} and n.read_at is null`)).rows[0]?.n;
155+ return { items: rows.rows, unread: Number(unread ?? 0) };
156+ });
157+ app.post("/api/v1/notifications/read", async (req, reply) => {
158+ const owner = requireOwner(req, reply);
159+ if (!owner) return;
160+ const body = z.object({ ids: z.array(z.number().int()).max(500).optional() }).parse(req.body ?? {});
161+ if (body.ids?.length) await db.execute(sql`update notifications n set read_at = now() from alerts a where a.id = n.alert_id and a.owner_token = ${owner} and n.id = any(${sql.raw("array[" + body.ids.map((i) => Number(i)).join(",") + "]::bigint[]")})`);
162+ else await db.execute(sql`update notifications n set read_at = now() from alerts a where a.id = n.alert_id and a.owner_token = ${owner} and n.read_at is null`);
163+ return { ok: true };
164+ });
165+
166+ // ---- Bookmarks ---------------------------------------------------------------------------------
167+ app.get<{ Querystring: { limit?: string } }>("/api/v1/bookmarks", async (req, reply) => {
168+ const owner = requireOwner(req, reply);
169+ if (!owner) return;
170+ const rows = await db.execute<Record<string, unknown>>(sql`select b.created_at as bookmarked_at, b.note, x.* from bookmarks b join lateral (select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = b.event_id) x on true where b.owner_token = ${owner} order by b.created_at desc limit ${Math.min(500, Number(req.query.limit ?? 100))}`);
171+ return { items: rows.rows };
172+ });
173+ app.post("/api/v1/bookmarks", async (req, reply) => {
174+ const owner = requireOwner(req, reply);
175+ if (!owner) return;
176+ const body = z.object({ event_id: z.string().min(4).max(80), note: z.string().max(500).optional() }).parse(req.body ?? {});
177+ const ev = (await db.execute<{ id: string }>(sql`select id from events where id = ${body.event_id} or slug = ${body.event_id}`)).rows[0];
178+ if (!ev) return reply.status(404).send({ error: "event_not_found" });
179+ const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from bookmarks where owner_token = ${owner}`)).rows[0]?.n;
180+ if (Number(count) >= 2000) return reply.status(429).send({ error: "too_many_bookmarks" });
181+ await db.execute(sql`insert into bookmarks (owner_token, event_id, note) values (${owner}, ${ev.id}, ${body.note ?? null}) on conflict (owner_token, event_id) do update set note = coalesce(excluded.note, bookmarks.note)`);
182+ return { ok: true, event_id: ev.id };
183+ });
184+ app.delete<{ Params: { id: string } }>("/api/v1/bookmarks/:id", async (req, reply) => {
185+ const owner = requireOwner(req, reply);
186+ if (!owner) return;
187+ await db.execute(sql`delete from bookmarks where owner_token = ${owner} and (event_id = ${req.params.id} or event_id = (select id from events where slug = ${req.params.id}))`);
188+ return { ok: true };
189+ });
190+ app.get("/api/v1/bookmarks/ids", async (req, reply) => {
191+ const owner = requireOwner(req, reply);
192+ if (!owner) return;
193+ const rows = await db.execute<{ event_id: string }>(sql`select event_id from bookmarks where owner_token = ${owner}`);
194+ return { ids: rows.rows.map((r) => r.event_id) };
195+ });
196+
197+ // ---- Saved views -------------------------------------------------------------------------------
198+ app.get("/api/v1/views", async (req, reply) => {
199+ const owner = requireOwner(req, reply);
200+ if (!owner) return;
201+ return { items: (await db.execute<Record<string, unknown>>(sql`select id, name, query, created_at from saved_views where owner_token = ${owner} order by created_at`)).rows };
202+ });
203+ app.post("/api/v1/views", async (req, reply) => {
204+ const owner = requireOwner(req, reply);
205+ if (!owner) return;
206+ const body = z.object({ name: z.string().min(1).max(60), query: z.string().min(1).max(600) }).parse(req.body ?? {});
207+ const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from saved_views where owner_token = ${owner}`)).rows[0]?.n;
208+ if (Number(count) >= 50) return reply.status(429).send({ error: "too_many_views" });
209+ const id = newId("view");
210+ await db.execute(sql`insert into saved_views (id, owner_token, name, query) values (${id}, ${owner}, ${body.name}, ${body.query})`);
211+ return { id, ...body };
212+ });
213+ app.delete<{ Params: { id: string } }>("/api/v1/views/:id", async (req, reply) => {
214+ const owner = requireOwner(req, reply);
215+ if (!owner) return;
216+ await db.execute(sql`delete from saved_views where id = ${req.params.id} and owner_token = ${owner}`);
217+ return { ok: true };
218+ });
219+
220+ // ---- Custom URL monitors (spec §45, §87) -----------------------------------------------------------
221+ const monitorBody = z.object({
222+ url: z.string().url().max(2000),
223+ name: z.string().min(1).max(80).optional(),
224+ /** hourly | daily (maps to tier C / D) */
225+ frequency: z.enum(["hourly", "daily"]).default("hourly"),
226+ /** low = only big changes, normal, high = every meaningful change */
227+ sensitivity: z.enum(["low", "normal", "high"]).default("normal"),
228+ selector: z.string().max(200).optional(),
229+ keywords: z.array(z.string().min(1).max(60)).max(20).optional(),
230+ });
231+ const ownerSourceId = (owner: string): string => `custom-${createHash("sha256").update(owner).digest("hex").slice(0, 12)}`;
232+
233+ app.get("/api/v1/monitors", async (req, reply) => {
234+ const owner = requireOwner(req, reply);
235+ if (!owner) return;
236+ const sid = ownerSourceId(owner);
237+ const rows = await db.execute<Record<string, unknown>>(sql`select id, name, url, tier, health, status, enabled, config, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, total_runs, raw_changes, meaningful_changes, created_at from sensors where source_id = ${sid} order by created_at`);
238+ return { items: rows.rows, limit: config.monitorsPerOwner, source_id: sid };
239+ });
240+ app.post("/api/v1/monitors", async (req, reply) => {
241+ const owner = requireOwner(req, reply);
242+ if (!owner) return;
243+ const body = monitorBody.parse(req.body ?? {});
244+ let host: string;
245+ try {
246+ const u = await assertUrlAllowed(body.url);
247+ if (!["http:", "https:"].includes(u.url.protocol)) return reply.status(400).send({ error: "scheme_not_allowed" });
248+ host = u.url.hostname;
249+ } catch (e) {
250+ return reply.status(400).send({ error: "url_rejected", detail: e instanceof UrlPolicyError ? e.message : "URL not allowed" });
251+ }
252+ const sid = ownerSourceId(owner);
253+ const n = Number((await db.execute<{ n: string }>(sql`select count(*)::text as n from sensors where source_id = ${sid} and enabled`)).rows[0]?.n ?? 0);
254+ if (n >= config.monitorsPerOwner) return reply.status(429).send({ error: "monitor_limit_reached", limit: config.monitorsPerOwner });
255+ // Sensor auto-test (spec §72): fetch + normalize before activation; baseline is taken by the engine.
256+ const sensorId = `${sid}_${newId("m").slice(2)}`;
257+ const cfg: Record<string, unknown> = { custom: true, sensitivity: body.sensitivity, ...(body.selector ? { selector: body.selector } : {}), ...(body.keywords?.length ? { keywords: body.keywords } : {}) };
258+ const tier = body.frequency === "hourly" ? "C" : "D";
259+ const connector = getConnector("http");
260+ const endpoint = { id: sensorId, sourceId: sid, name: body.name ?? host, url: body.url, type: "HTML" as const, tier: tier as "C" | "D", connector: "http", config: cfg, etag: null, lastModified: null, state: null };
261+ const obs = await connector.fetch(endpoint);
262+ if (obs.error) return reply.status(422).send({ error: "fetch_failed", detail: `${obs.error.code}: ${obs.error.message}` });
263+ if (obs.meta.status >= 400) return reply.status(422).send({ error: "http_error", status: obs.meta.status });
264+ let norm;
265+ try {
266+ norm = await connector.normalize(endpoint, obs);
267+ } catch (e) {
268+ return reply.status(422).send({ error: "unparseable", detail: (e as Error).message });
269+ }
270+ if ((norm.text ?? "").length < 40 && norm.mode === "text") return reply.status(422).send({ error: "thin_content", detail: "The page has almost no server-rendered text (client-side app?)." });
271+ await db.execute(sql`insert into sources (id, name, domain, homepage, description, categories, tier, importance_weight, enabled, llm_enabled, kind, owner_token, first_party, notes)
272+ values (${sid}, ${"Custom monitors"}, ${"custom.websensor.io"}, ${null}, ${"Private URL monitors"}, ${"{custom}"}::text[], 'C', 0.5, true, false, 'custom', ${owner}, true, 'owner-scoped custom monitors') on conflict (id) do nothing`);
273+ await db.execute(sql`insert into sensors (id, source_id, name, url, type, connector, tier, importance_weight, config, priority, status, validated_at, enabled, next_check_at)
274+ values (${sensorId}, ${sid}, ${body.name ?? host}, ${body.url}, 'HTML', 'http', ${tier}, ${body.sensitivity === "high" ? 1.3 : body.sensitivity === "low" ? 0.7 : 1}, ${JSON.stringify(cfg)}::jsonb, 3, 'VALIDATED', now(), true, now())`);
275+ return { id: sensorId, source_id: sid, url: body.url, tier, status: "VALIDATED", test: { http_status: obs.meta.status, content_type: obs.meta.contentType, bytes: obs.meta.contentLength, title: norm.title ?? null, mode: norm.mode, extraction_confidence: norm.extractionConfidence } };
276+ });
277+ app.delete<{ Params: { id: string } }>("/api/v1/monitors/:id", async (req, reply) => {
278+ const owner = requireOwner(req, reply);
279+ if (!owner) return;
280+ const sid = ownerSourceId(owner);
281+ await db.execute(sql`delete from sensors where id = ${req.params.id} and source_id = ${sid}`);
282+ return { ok: true };
283+ });
284+ app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/monitors/:id/events", async (req, reply) => {
285+ const owner = requireOwner(req, reply);
286+ if (!owner) return;
287+ const sid = ownerSourceId(owner);
288+ const sensor = (await db.execute<{ id: string }>(sql`select id from sensors where id = ${req.params.id} and source_id = ${sid}`)).rows[0];
289+ if (!sensor) return reply.status(404).send({ error: "not_found" });
290+ const [events, changes] = await Promise.all([
291+ listEvents({ sensor: sensor.id, limit: Math.min(100, Number(req.query.limit ?? 30)), includeCustom: true }),
292+ db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, change_class, field_changes, meaningful, event_id, old_snapshot_id, new_snapshot_id from changes where sensor_id = ${sensor.id} order by detected_at desc limit 30`).then((r) => r.rows),
293+ ]);
294+ return { events: events.items, changes };
295+ });
296+
297+ // keep eventConditions referenced for owner-scoped searches later
298+ void eventConditions;
299+}
modified apps/api/src/routes.ts +226 −173
@@ -1,13 +1,20 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import { z } from "zod"; 2 import { z } from "zod";
3−import { diffText, EVENT_TYPES, FEED_CHANNELS, newId } from "@websensor/core"; 3+import { COUNTRIES, countryBySlug, diffText, EVENT_GROUPS, EVENT_TYPES, FEED_CHANNELS, parseSearch } from "@websensor/core";
4 import { db, sql, textArray } from "@websensor/db"; 4 import { db, sql, textArray } from "@websensor/db";
5 import { getBlobStore } from "@websensor/store"; 5 import { getBlobStore } from "@websensor/store";
6+import { cached } from "./cache";
6 import { config } from "./config"; 7 import { config } from "./config";
7−import { liveStats } from "./live"; 8+import { breakingDesk, categoryDesk, clusterDetail, countryDesk, countryList, entityInsights, entityRankings, pulse, radar, sourceSensors } from "./intel";
8−import { getEvent, listEvents, relatedEvents, sourceActivity, stats, trending, EVENT_SELECT } from "./queries"; 9+import { engineStatus, liveStats } from "./live";
10+import { countEvents, getEvent, listEvents, relatedEvents, sensorHistory, sourceActivity, sourceQuality, stats, trending } from "./queries";
9 11
10−const eventsQuery = z.object({ 12+const bool = z
13+ .enum(["true", "false", "1", "0"])
14+ .transform((v) => v === "true" || v === "1")
15+ .optional();
16+
17+export const eventsQuery = z.object({
11 after: z.string().optional(), 18 after: z.string().optional(),
12 before: z.string().optional(), 19 before: z.string().optional(),
13 category: z.string().optional(), 20 category: z.string().optional(),
@@ -18,56 +25,66 @@ const eventsQuery = z.object({
18 cluster: z.string().optional(), 25 cluster: z.string().optional(),
19 importance_min: z.coerce.number().min(0).max(100).optional(), 26 importance_min: z.coerce.number().min(0).max(100).optional(),
20 confidence_min: z.coerce.number().min(0).max(100).optional(), 27 confidence_min: z.coerce.number().min(0).max(100).optional(),
28+ signal_min: z.coerce.number().min(0).max(100).optional(),
21 event_type: z.string().optional(), 29 event_type: z.string().optional(),
22− silent_change: z 30+ group: z.string().optional(),
23− .enum(["true", "false"]) 31+ silent_change: bool,
24− .transform((v) => v === "true") 32+ first_party: bool,
25− .optional(), 33+ confirmed: bool,
26− q: z.string().max(200).optional(), 34+ country: z.string().max(3).optional(),
35+ language: z.string().max(2).optional(),
36+ change_class: z.string().optional(),
37+ q: z.string().max(300).optional(),
27 limit: z.coerce.number().int().min(1).max(200).default(50), 38 limit: z.coerce.number().int().min(1).max(200).default(50),
28 cursor: z.string().optional(), 39 cursor: z.string().optional(),
29− order: z.enum(["recent", "importance"]).default("recent"), 40+ order: z.enum(["recent", "importance", "signal"]).default("recent"),
30 }); 41 });
31 42
32−function ownerToken(headers: Record<string, unknown>): string | null {
33− const t = headers["x-websensor-owner"];
34− return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null;
35−}
36−
37 export async function registerRoutes(app: FastifyInstance): Promise<void> { 43 export async function registerRoutes(app: FastifyInstance): Promise<void> {
38 // ---- Health --------------------------------------------------------------------------- 44 // ---- Health ---------------------------------------------------------------------------
39 app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() })); 45 app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() }));
40 app.get("/api/ready", async (_req, reply) => { 46 app.get("/api/ready", async (_req, reply) => {
41− const checks: Record<string, boolean | number> = {}; 47+ const checks: Record<string, boolean | number | string | null> = {};
48+ const t0 = Date.now();
42 try { 49 try {
43 await db.execute(sql`select 1`); 50 await db.execute(sql`select 1`);
44 checks.database = true; 51 checks.database = true;
52+ checks.database_latency_ms = Date.now() - t0;
45 } catch { 53 } catch {
46 checks.database = false; 54 checks.database = false;
47 } 55 }
48− const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`); 56+ const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`).catch(() => ({ rows: [{ last: null }] }));
49 checks.engine_recent = Boolean(engine.rows[0]?.last); 57 checks.engine_recent = Boolean(engine.rows[0]?.last);
58+ const es = await engineStatus();
59+ checks.engine_heartbeat = es ? String(es.at) : null;
50 checks.ws_clients = liveStats().clients; 60 checks.ws_clients = liveStats().clients;
51 return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks }); 61 return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks });
52 }); 62 });
53 63
54 // ---- Events --------------------------------------------------------------------------- 64 // ---- Events ---------------------------------------------------------------------------
55− app.get("/api/v1/events", async (req) => { 65+ app.get("/api/v1/events", async (req, reply) => {
66+ const q = eventsQuery.parse(req.query);
67+ const page = await listEvents(q);
68+ reply.header("x-websensor-order", q.order);
69+ return { ...page, meta: { limit: q.limit, order: q.order, filters: stripEmpty({ ...q, limit: undefined, cursor: undefined, order: undefined }) } };
70+ });
71+ app.get("/api/v1/events/count", async (req) => {
56 const q = eventsQuery.parse(req.query); 72 const q = eventsQuery.parse(req.query);
57− return listEvents(q); 73+ return { count: await countEvents(q) };
58 }); 74 });
59 app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => { 75 app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => {
60 const ev = await getEvent(req.params.id); 76 const ev = await getEvent(req.params.id);
61 if (!ev) return reply.status(404).send({ error: "not_found" }); 77 if (!ev) return reply.status(404).send({ error: "not_found" });
62− const [related, cluster, change, interp] = await Promise.all([ 78+ const [related, cluster, change, interp, history] = await Promise.all([
63 relatedEvents(ev), 79 relatedEvents(ev),
64 ev.cluster_id ? db.execute<Record<string, unknown>>(sql`select * from event_clusters where id = ${String(ev.cluster_id)}`).then((r) => r.rows[0] ?? null) : null, 80 ev.cluster_id ? db.execute<Record<string, unknown>>(sql`select * from event_clusters where id = ${String(ev.cluster_id)}`).then((r) => r.rows[0] ?? null) : null,
65− ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null, 81+ ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id, change_class, field_changes from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null,
66 db.execute<Record<string, unknown>>(sql`select version, model, created_at from interpretations where event_id = ${String(ev.id)} order by version`).then((r) => r.rows), 82 db.execute<Record<string, unknown>>(sql`select version, model, created_at from interpretations where event_id = ${String(ev.id)} order by version`).then((r) => r.rows),
83+ sensorHistory(String(ev.sensor_id), String(ev.id)),
67 ]); 84 ]);
68− const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`); 85+ const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence, storage_key is not null as has_raw from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`);
69 const sourceRel = await db.execute<Record<string, unknown>>(sql`select health, success_rate, avg_latency_ms, total_runs, raw_changes, meaningful_changes, last_check_at from (select s.health, ch.success_rate, s.avg_latency_ms, s.total_runs, s.raw_changes, s.meaningful_changes, s.last_check_at from sensors s left join connector_health ch on ch.connector = s.connector where s.id = ${String(ev.sensor_id)}) x`); 86 const sourceRel = await db.execute<Record<string, unknown>>(sql`select health, success_rate, avg_latency_ms, total_runs, raw_changes, meaningful_changes, last_check_at from (select s.health, ch.success_rate, s.avg_latency_ms, s.total_runs, s.raw_changes, s.meaningful_changes, s.last_check_at from sensors s left join connector_health ch on ch.connector = s.connector where s.id = ${String(ev.sensor_id)}) x`);
70− return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null }; 87+ return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null, history };
71 }); 88 });
72 89
73 // ---- Changes / snapshots / diffs ------------------------------------------------------- 90 // ---- Changes / snapshots / diffs -------------------------------------------------------
@@ -84,7 +101,8 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
84 const s = r.rows[0]; 101 const s = r.rows[0];
85 if (!s) return reply.status(404).send({ error: "not_found" }); 102 if (!s) return reply.status(404).send({ error: "not_found" });
86 const store = getBlobStore(); 103 const store = getBlobStore();
87− if (req.query.raw === "1" && s.storage_key) { 104+ if (req.query.raw === "1") {
105+ if (!s.storage_key) return reply.status(410).send({ error: "raw_body_pruned", detail: "The raw body of this snapshot was pruned by the retention policy; the canonical representation and hashes are preserved." });
88 const buf = await store.get(String(s.storage_key)); 106 const buf = await store.get(String(s.storage_key));
89 reply.header("content-type", String(s.content_type ?? "text/plain") + (String(s.content_type ?? "").includes("charset") ? "" : "; charset=utf-8")); 107 reply.header("content-type", String(s.content_type ?? "text/plain") + (String(s.content_type ?? "").includes("charset") ? "" : "; charset=utf-8"));
90 reply.header("x-content-type-options", "nosniff"); 108 reply.header("x-content-type-options", "nosniff");
@@ -108,76 +126,111 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
108 const d = diffText(ta, tb, `${a}@${String(sa.captured_at)}`, `${b}@${String(sb.captured_at)}`); 126 const d = diffText(ta, tb, `${a}@${String(sa.captured_at)}`, `${b}@${String(sb.captured_at)}`);
109 return { a: sa, b: sb, before: ta, after: tb, diff: { unified: d.unified, stats: d.stats, added: d.added, removed: d.removed, modified: d.modified } }; 127 return { a: sa, b: sb, before: ta, after: tb, diff: { unified: d.unified, stats: d.stats, added: d.added, removed: d.removed, modified: d.modified } };
110 }); 128 });
129+ /** Historical memory (spec §79): how a sensor's page looked at any point — list of snapshots with day grouping. */
130+ app.get<{ Params: { id: string }; Querystring: { limit?: string; before?: string } }>("/api/v1/sensors/:id/snapshots", async (req) => {
131+ const lim = Math.min(500, Number(req.query.limit ?? 200));
132+ const rows = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title, mode, storage_key is not null as has_raw, (select count(*) from changes c where c.new_snapshot_id = snapshots.id) > 0 as has_change, (select e.slug from events e where e.new_snapshot_id = snapshots.id limit 1) as event_slug from snapshots where sensor_id = ${req.params.id} ${req.query.before ? sql`and captured_at < ${new Date(req.query.before)}` : sql``} order by captured_at desc limit ${lim}`);
133+ return { items: rows.rows };
134+ });
111 135
112 // ---- Sources & sensors ------------------------------------------------------------------ 136 // ---- Sources & sensors ------------------------------------------------------------------
113− app.get<{ Querystring: { category?: string; q?: string; limit?: string } }>("/api/v1/sources", async (req) => { 137+ app.get<{ Querystring: { category?: string; q?: string; limit?: string; country?: string; tier?: string; first_party?: string } }>("/api/v1/sources", async (req) => {
114 const cat = req.query.category; 138 const cat = req.query.category;
115 const q = req.query.q; 139 const q = req.query.q;
116 const rows = await db.execute<Record<string, unknown>>(sql` 140 const rows = await db.execute<Record<string, unknown>>(sql`
117− select s.*, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, 141+ select s.id, s.name, s.domain, s.homepage, s.description, s.categories, s.tier, s.importance_weight, s.enabled, s.notes, s.first_party, s.country, s.language, s.robots_checked_at,
142+ (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count,
118 (select count(*) from events e where e.source_id = s.id)::int as event_count, 143 (select count(*) from events e where e.source_id = s.id)::int as event_count,
119 (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h, 144 (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h,
120 (select max(detected_at) from events e where e.source_id = s.id) as last_event_at, 145 (select max(detected_at) from events e where e.source_id = s.id) as last_event_at,
121 (select max(last_check_at) from sensors x where x.source_id = s.id) as last_check_at, 146 (select max(last_check_at) from sensors x where x.source_id = s.id) as last_check_at,
122 (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.health <> 'UP')::int as sensors_degraded 147 (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.health <> 'UP')::int as sensors_degraded
123− from sources s where s.enabled ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``} 148+ from sources s where s.enabled and s.kind = 'registry' ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``} ${req.query.country ? sql`and s.country = ${req.query.country.toUpperCase()}` : sql``} ${req.query.tier ? sql`and s.tier = ${req.query.tier.toUpperCase()}` : sql``} ${req.query.first_party ? sql`and s.first_party = ${req.query.first_party === "true"}` : sql``}
124− order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(500, Number(req.query.limit ?? 300))}`); 149+ order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(5000, Number(req.query.limit ?? 300))}`);
125 return { items: rows.rows }; 150 return { items: rows.rows };
126 }); 151 });
127 app.get<{ Params: { id: string } }>("/api/v1/sources/:id", async (req, reply) => { 152 app.get<{ Params: { id: string } }>("/api/v1/sources/:id", async (req, reply) => {
128− const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where id = ${req.params.id} or domain = ${req.params.id} limit 1`)).rows[0]; 153+ const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where (id = ${req.params.id} or domain = ${req.params.id}) and kind = 'registry' limit 1`)).rows[0];
129 if (!s) return reply.status(404).send({ error: "not_found" }); 154 if (!s) return reply.status(404).send({ error: "not_found" });
130− const [sensors, ents, activity, candidates] = await Promise.all([ 155+ const { owner_token: _o, ...pub } = s;
131− db.execute<Record<string, unknown>>(sql`select id, name, url, type, connector, tier, health, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, etag is not null as has_etag, last_modified is not null as has_last_modified from sensors where source_id = ${String(s.id)} order by tier, name`).then((r) => r.rows), 156+ const [sensors, ents, activity, quality, candidates, byType, series] = await Promise.all([
157+ sourceSensors(String(s.id)),
132 db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, en.importance, en.event_count from source_entities se join entities en on en.id = se.entity_id where se.source_id = ${String(s.id)} order by en.type, en.name`).then((r) => r.rows), 158 db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, en.importance, en.event_count from source_entities se join entities en on en.id = se.entity_id where se.source_id = ${String(s.id)} order by en.type, en.name`).then((r) => r.rows),
133 sourceActivity(String(s.id)), 159 sourceActivity(String(s.id)),
160+ sourceQuality(String(s.id)),
134 db.execute<Record<string, unknown>>(sql`select url, kind, evidence, score, status, found_at from discovery_candidates where source_id = ${String(s.id)} order by (score->>'value')::float desc nulls last limit 50`).then((r) => r.rows), 161 db.execute<Record<string, unknown>>(sql`select url, kind, evidence, score, status, found_at from discovery_candidates where source_id = ${String(s.id)} order by (score->>'value')::float desc nulls last limit 50`).then((r) => r.rows),
162+ db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where source_id = ${String(s.id)} group by 1 order by 2 desc limit 16`).then((r) => r.rows),
163+ db.execute<Record<string, unknown>>(sql`select day::text as day, checks, not_modified, errors, raw_changes, events from source_daily where source_id = ${String(s.id)} and day >= (now() at time zone 'UTC')::date - 30 order by day`).then((r) => r.rows),
135 ]); 164 ]);
136− return { source: s, sensors, entities: ents, activity, discovery: candidates }; 165+ return { source: pub, sensors, entities: ents, activity, quality, discovery: candidates, by_type: byType, daily: series };
137 }); 166 });
138 app.get<{ Params: { id: string } }>("/api/v1/sensors/:id", async (req, reply) => { 167 app.get<{ Params: { id: string } }>("/api/v1/sensors/:id", async (req, reply) => {
139− const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0]; 168+ const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain, so.kind as source_kind from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0];
140− if (!s) return reply.status(404).send({ error: "not_found" }); 169+ if (!s || s.source_kind === "custom") return reply.status(404).send({ error: "not_found" });
141− const [runs, snaps, changes] = await Promise.all([ 170+ const [runs, snaps, changes, events] = await Promise.all([
142 db.execute<Record<string, unknown>>(sql`select id, started_at, finished_at, http_status, outcome, error, duration_ms, bytes, fetch_method, snapshot_id from sensor_runs where sensor_id = ${String(s.id)} order by started_at desc limit 50`).then((r) => r.rows), 171 db.execute<Record<string, unknown>>(sql`select id, started_at, finished_at, http_status, outcome, error, duration_ms, bytes, fetch_method, snapshot_id from sensor_runs where sensor_id = ${String(s.id)} order by started_at desc limit 50`).then((r) => r.rows),
143− db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows), 172+ db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence, storage_key is not null as has_raw from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows),
144− db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows), 173+ db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, change_class, field_changes, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows),
174+ listEvents({ sensor: String(s.id), limit: 30 }).then((r) => r.items),
145 ]); 175 ]);
146− const { etag: _e, last_modified: _lm, state: _st, ...pub } = s as Record<string, unknown>; 176+ const { state: _st, source_kind: _k, ...pub } = s as Record<string, unknown>;
147− return { sensor: pub, runs, snapshots: snaps, changes }; 177+ const runs24 = await db.execute<Record<string, unknown>>(sql`select count(*)::int as checks_24h, count(*) filter (where outcome = 'not_modified')::int as not_modified_24h, count(*) filter (where outcome in ('error','parse_error','rate_limited'))::int as errors_24h, avg(duration_ms)::int as avg_ms_24h from sensor_runs where sensor_id = ${String(s.id)} and started_at >= now() - interval '24 hours'`);
178+ return { sensor: { ...pub, ...runs24.rows[0] }, runs, snapshots: snaps, changes, events };
148 }); 179 });
149 180
150 // ---- Entities ---------------------------------------------------------------------------- 181 // ---- Entities ----------------------------------------------------------------------------
151− app.get<{ Querystring: { type?: string; q?: string; limit?: string } }>("/api/v1/entities", async (req) => { 182+ app.get<{ Querystring: { type?: string; q?: string; limit?: string; category?: string } }>("/api/v1/entities", async (req) => {
152 const rows = await db.execute<Record<string, unknown>>(sql` 183 const rows = await db.execute<Record<string, unknown>>(sql`
153 select en.*, (select count(*) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id and e.detected_at >= now() - interval '24 hours')::int as events_24h 184 select en.*, (select count(*) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id and e.detected_at >= now() - interval '24 hours')::int as events_24h
154− from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``} 185+ from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.category ? sql`and ${req.query.category} = any(en.categories)` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``}
155− order by en.event_count desc, en.importance desc, en.name limit ${Math.min(500, Number(req.query.limit ?? 200))}`); 186+ order by en.event_count desc, en.importance desc, en.name limit ${Math.min(1000, Number(req.query.limit ?? 200))}`);
156 return { items: rows.rows }; 187 return { items: rows.rows };
157 }); 188 });
189+ app.get<{ Querystring: { limit?: string } }>("/api/v1/entities/rank", async (req) => ({ items: await entityRankings(Math.min(500, Number(req.query.limit ?? 100))) }));
158 app.get<{ Params: { id: string } }>("/api/v1/entities/:id", async (req, reply) => { 190 app.get<{ Params: { id: string } }>("/api/v1/entities/:id", async (req, reply) => {
159 const e = (await db.execute<Record<string, unknown>>(sql`select * from entities where id = ${req.params.id} or id = ${"org_" + req.params.id} limit 1`)).rows[0]; 191 const e = (await db.execute<Record<string, unknown>>(sql`select * from entities where id = ${req.params.id} or id = ${"org_" + req.params.id} limit 1`)).rows[0];
160 if (!e) return reply.status(404).send({ error: "not_found" }); 192 if (!e) return reply.status(404).send({ error: "not_found" });
161 const id = String(e.id); 193 const id = String(e.id);
162− const [children, relations, sources, aliases, recent, byType] = await Promise.all([ 194+ const [children, relations, sources, aliases, recent, byType, insights, silent, parent] = await Promise.all([
163 db.execute<Record<string, unknown>>(sql`select id, name, type, importance, event_count, last_event_at from entities where parent_id = ${id} order by event_count desc, name`).then((r) => r.rows), 195 db.execute<Record<string, unknown>>(sql`select id, name, type, importance, event_count, last_event_at from entities where parent_id = ${id} order by event_count desc, name`).then((r) => r.rows),
164 db.execute<Record<string, unknown>>(sql`select r.relation, r.from_id, r.to_id, f.name as from_name, t.name as to_name, t.type as to_type from entity_relations r join entities f on f.id = r.from_id join entities t on t.id = r.to_id where r.from_id = ${id} or r.to_id = ${id} limit 100`).then((r) => r.rows), 196 db.execute<Record<string, unknown>>(sql`select r.relation, r.from_id, r.to_id, f.name as from_name, t.name as to_name, t.type as to_type from entity_relations r join entities f on f.id = r.from_id join entities t on t.id = r.to_id where r.from_id = ${id} or r.to_id = ${id} limit 100`).then((r) => r.rows),
165− db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id}`).then((r) => r.rows), 197+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.first_party, s.country, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, (select count(*) from events ev where ev.source_id = s.id and ev.detected_at >= now() - interval '24 hours')::int as events_24h from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id} and s.kind = 'registry'`).then((r) => r.rows),
166 db.execute<{ alias: string }>(sql`select alias from entity_aliases where entity_id = ${id} order by alias`).then((r) => r.rows.map((x) => x.alias)), 198 db.execute<{ alias: string }>(sql`select alias from entity_aliases where entity_id = ${id} order by alias`).then((r) => r.rows.map((x) => x.alias)),
167 listEvents({ entity: id, limit: 30 }), 199 listEvents({ entity: id, limit: 30 }),
168 db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${id} group by 1 order by 2 desc`).then((r) => r.rows), 200 db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${id} group by 1 order by 2 desc`).then((r) => r.rows),
201+ entityInsights(id),
202+ listEvents({ entity: id, silent_change: true, limit: 10 }).then((r) => r.items),
203+ e.parent_id ? db.execute<Record<string, unknown>>(sql`select id, name, type from entities where id = ${String(e.parent_id)}`).then((r) => r.rows[0] ?? null) : null,
169 ]); 204 ]);
170− return { entity: e, children, relations, sources, aliases, recent: recent.items, by_type: byType }; 205+ // Related entities: co-occurring in events over 30 days
206+ const related = await db.execute<Record<string, unknown>>(sql`
207+ select en.id, en.name, en.type, count(*)::int as shared_events from event_entities a join event_entities b on a.event_id = b.event_id and b.entity_id <> a.entity_id join events e on e.id = a.event_id join entities en on en.id = b.entity_id
208+ where a.entity_id = ${id} and e.detected_at >= now() - interval '30 days' group by en.id, en.name, en.type order by shared_events desc limit 12`).then((r) => r.rows);
209+ return { entity: e, parent, children, relations, sources, aliases, recent: recent.items, nextCursor: recent.nextCursor, by_type: byType, insights, silent, related };
171 }); 210 });
172− app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/entities/:id/timeline", async (req) => { 211+ app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string; after?: string; before?: string; event_type?: string; silent_change?: string } }>("/api/v1/entities/:id/timeline", async (req) => {
173 const id = req.params.id.startsWith("org_") || req.params.id.startsWith("prd_") ? req.params.id : `org_${req.params.id}`; 212 const id = req.params.id.startsWith("org_") || req.params.id.startsWith("prd_") ? req.params.id : `org_${req.params.id}`;
174− return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }); 213+ return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor, after: req.query.after, before: req.query.before, event_type: req.query.event_type, silent_change: req.query.silent_change === "true" ? true : undefined });
214+ });
215+
216+ // ---- Clusters ------------------------------------------------------------------------------
217+ app.get<{ Querystring: { limit?: string; since?: string; state?: string; min_events?: string } }>("/api/v1/clusters", async (req) => {
218+ const rows = await db.execute<Record<string, unknown>>(sql`
219+ select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url, 'first_party', e.first_party) order by e.importance desc) from events e where e.cluster_id = c.id) as events,
220+ (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source
221+ from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) ${req.query.state ? sql`and c.state = ${req.query.state}` : sql``} ${req.query.min_events ? sql`and c.event_count >= ${Number(req.query.min_events)}` : sql``} order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
222+ return { items: rows.rows };
223+ });
224+ app.get<{ Params: { id: string } }>("/api/v1/clusters/:id", async (req, reply) => {
225+ const d = await clusterDetail(req.params.id);
226+ if (!d) return reply.status(404).send({ error: "not_found" });
227+ return d;
175 }); 228 });
176 229
177 // ---- Domains / URLs ---------------------------------------------------------------------- 230 // ---- Domains / URLs ----------------------------------------------------------------------
178 app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => { 231 app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => {
179 const d = req.params.domain.toLowerCase(); 232 const d = req.params.domain.toLowerCase();
180− const src = (await db.execute<{ id: string }>(sql`select id from sources where domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain limit 1`)).rows[0]; 233+ const src = (await db.execute<{ id: string }>(sql`select id from sources where (domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain) and kind = 'registry' limit 1`)).rows[0];
181 const urls = await db.execute<Record<string, unknown>>(sql`select url, status, first_seen_at, last_seen_at, snapshot_count, change_count, sensor_id from urls where domain = ${d} or domain like ${"%." + d} order by change_count desc, last_seen_at desc limit 200`); 234 const urls = await db.execute<Record<string, unknown>>(sql`select url, status, first_seen_at, last_seen_at, snapshot_count, change_count, sensor_id from urls where domain = ${d} or domain like ${"%." + d} order by change_count desc, last_seen_at desc limit 200`);
182 const ev = src ? await listEvents({ source: src.id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }) : { items: [], nextCursor: null }; 235 const ev = src ? await listEvents({ source: src.id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }) : { items: [], nextCursor: null };
183 return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor }; 236 return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor };
@@ -186,149 +239,103 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
186 if (!req.query.url) return reply.status(400).send({ error: "url required" }); 239 if (!req.query.url) return reply.status(400).send({ error: "url required" });
187 const u = (await db.execute<Record<string, unknown>>(sql`select * from urls where url = ${req.query.url}`)).rows[0]; 240 const u = (await db.execute<Record<string, unknown>>(sql`select * from urls where url = ${req.query.url}`)).rows[0];
188 const history = await db.execute<Record<string, unknown>>(sql` 241 const history = await db.execute<Record<string, unknown>>(sql`
189− select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal 242+ select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal, c.change_class
190 from url_history h left join events e on e.id = h.event_id left join changes c on c.id = h.change_id where h.url = ${req.query.url} order by h.at desc limit 300`); 243 from url_history h left join events e on e.id = h.event_id left join changes c on c.id = h.change_id where h.url = ${req.query.url} order by h.at desc limit 300`);
191− const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`); 244+ const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title, storage_key is not null as has_raw from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`);
192 return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows }; 245 return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows };
193 }); 246 });
194 247
195− // ---- Explore / trending / stats ------------------------------------------------------------ 248+ // ---- Intelligence desks ----------------------------------------------------------------------
196− app.get("/api/v1/stats", async () => stats()); 249+ app.get("/api/v1/stats", async () => cached("stats", 5_000, stats));
197− app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => ({ items: await trending(Math.min(168, Number(req.query.hours ?? 24)), Math.min(50, Number(req.query.limit ?? 12))) })); 250+ app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => {
198− app.get("/api/v1/explore", async () => { 251+ const hours = Math.min(168, Number(req.query.hours ?? 24));
199− const [mostActive, biggest, silent, clusters, unusual, byType, byCategory] = await Promise.all([ 252+ const limit = Math.min(50, Number(req.query.limit ?? 12));
200− db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' group by s.id, s.name, s.domain order by events_24h desc limit 10`).then((r) => r.rows), 253+ return { items: await cached(`trending:${hours}:${limit}`, 15_000, () => trending(hours, limit)) };
201− listEvents({ limit: 10, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items),
202− listEvents({ limit: 10, silent_change: true }).then((r) => r.items),
203− db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 10`).then((r) => r.rows),
204− db.execute<Record<string, unknown>>(sql`
205− with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id),
206− base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id)
207− select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
208− round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score
209− from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id order by activity_score desc limit 10`).then((r) => r.rows),
210− db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
211− db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
212− ]);
213− return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) };
214 }); 254 });
215− app.get<{ Querystring: { limit?: string; since?: string } }>("/api/v1/clusters", async (req) => { 255+ app.get("/api/v1/breaking", async () => breakingDesk());
216− const rows = await db.execute<Record<string, unknown>>(sql` 256+ app.get("/api/v1/pulse", async () => pulse());
217− select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url) order by e.importance desc) from events e where e.cluster_id = c.id) as events 257+ app.get("/api/v1/radar", async () => radar());
218− from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); 258+ app.get("/api/v1/countries", async () => ({ items: await countryList(), known: COUNTRIES }));
219− return { items: rows.rows }; 259+ app.get<{ Params: { code: string } }>("/api/v1/countries/:code", async (req, reply) => {
260+ const c = countryBySlug(req.params.code) ?? (COUNTRIES[req.params.code.toUpperCase()] ? { code: req.params.code.toUpperCase() } : null);
261+ if (!c) return reply.status(404).send({ error: "not_found" });
262+ return cached(`country:${c.code}`, 20_000, () => countryDesk(c.code));
263+ });
264+ app.get<{ Params: { channel: string } }>("/api/v1/categories/:channel", async (req, reply) => {
265+ const ch = req.params.channel.toLowerCase();
266+ if (!FEED_CHANNELS[ch] && !/^[a-z-]{2,30}$/.test(ch)) return reply.status(404).send({ error: "not_found" });
267+ return cached(`category:${ch}`, 15_000, () => categoryDesk(ch));
220 }); 268 });
269+ app.get("/api/v1/explore", async () =>
270+ cached("explore", 20_000, async () => {
271+ const since48 = new Date(Date.now() - 48 * 3600e3).toISOString();
272+ const [mostActive, biggest, silent, clusters, unusual, byType, byCategory, newly, firstParty] = await Promise.all([
273+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.first_party, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' and s.kind = 'registry' group by s.id, s.name, s.domain, s.first_party order by events_24h desc limit 12`).then((r) => r.rows),
274+ listEvents({ limit: 12, order: "signal", after: since48 }).then((r) => r.items),
275+ listEvents({ limit: 12, silent_change: true, order: "signal", after: since48 }).then((r) => r.items),
276+ db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source, (select e.slug from events e where e.id = c.primary_event_id) as primary_slug from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 12`).then((r) => r.rows),
277+ db.execute<Record<string, unknown>>(sql`
278+ with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id),
279+ base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id)
280+ select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
281+ round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score,
282+ (case when coalesce(base.per_hour,0) > 0 then round(((cur.n/2.0/base.per_hour - 1) * 100)::numeric) else null end)::int as pct_vs_baseline
283+ from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id where so.kind = 'registry' order by activity_score desc limit 12`).then((r) => r.rows),
284+ db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
285+ db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
286+ listEvents({ limit: 12, order: "recent", confidence_min: 60 }).then((r) => r.items),
287+ listEvents({ limit: 12, order: "signal", first_party: true, confirmed: true, after: since48 }).then((r) => r.items),
288+ ]);
289+ return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, newly_detected: newly, confirmed_first_party: firstParty, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, groups: Object.fromEntries(Object.entries(EVENT_GROUPS).map(([k, v]) => [k, v.label])), event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) };
290+ }),
291+ );
221 292
222 // ---- Search ---------------------------------------------------------------------------- 293 // ---- Search ----------------------------------------------------------------------------
223 app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => { 294 app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => {
224 const q = (req.query.q ?? "").trim(); 295 const q = (req.query.q ?? "").trim();
225− if (q.length < 2) return { query: q, events: [], entities: [], sources: [], urls: [] }; 296+ const parsed = parseSearch(q);
297+ if (q.length < 2) return { query: q, parsed, events: [], entities: [], sources: [], urls: [], clusters: [] };
226 const lim = Math.min(50, Number(req.query.limit ?? 20)); 298 const lim = Math.min(50, Number(req.query.limit ?? 20));
227− const [ev, ents, srcs, urls] = await Promise.all([ 299+ const free = parsed.text;
228− listEvents({ q, limit: lim }).then((r) => r.items), 300+ const [ev, ents, srcs, urls, clusters] = await Promise.all([
229− db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${q}) or name ilike ${"%" + q + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows), 301+ listEvents({ q, limit: lim, order: Object.keys(parsed.filters).length && !free ? "recent" : "recent" }).then((r) => r.items),
230− db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories from sources where name ilike ${"%" + q + "%"} or domain ilike ${"%" + q + "%"} limit ${lim}`).then((r) => r.rows), 302+ free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${free}) or name ilike ${"%" + free + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows) : [],
231− db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + q + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows), 303+ free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories, first_party, country from sources where kind = 'registry' and (name ilike ${"%" + free + "%"} or domain ilike ${"%" + free + "%"}) limit ${lim}`).then((r) => r.rows) : [],
304+ free.length >= 4 ? db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + free + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows) : [],
305+ free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, slug, title, state, event_count, source_count, max_importance, last_at from event_clusters where title ilike ${"%" + free + "%"} and last_at >= now() - interval '30 days' order by max_importance desc limit 10`).then((r) => r.rows) : [],
232 ]); 306 ]);
233− return { query: q, events: ev, entities: ents, sources: srcs, urls }; 307+ return { query: q, parsed, events: ev, entities: ents, sources: srcs, urls, clusters };
234 }); 308 });
235 309
236− // ---- Connector health ---------------------------------------------------------------------- 310+ // ---- Connector health / system --------------------------------------------------------------
237− app.get("/api/v1/health/connectors", async () => { 311+ app.get("/api/v1/health/connectors", async () =>
238− const [connectors, sensorsByHealth, worst, noisy, daily] = await Promise.all([ 312+ cached("health", 10_000, async () => {
239− db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows), 313+ const [connectors, sensorsByHealth, sensorsByStatus, worst, noisy, daily, byConnectorSensors, throughput, es, topFailingDomains, slowest] = await Promise.all([
240− db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows), 314+ db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows),
241− db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows), 315+ db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows),
242− db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows), 316+ db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from sensors group by status`).then((r) => r.rows),
243− db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows), 317+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows),
244− ]); 318+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows),
245− return { connectors, sensors_by_health: sensorsByHealth, degraded_sensors: worst, noisy_sensors: noisy, daily, live: liveStats() }; 319+ db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows),
246− }); 320+ db.execute<Record<string, unknown>>(sql`select connector, count(*)::int as sensors, count(*) filter (where health = 'UP')::int as up, avg(avg_latency_ms)::int as avg_latency_ms from sensors where enabled group by connector order by sensors desc`).then((r) => r.rows),
247− 321+ db.execute<Record<string, unknown>>(sql`select (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes')::int as checks_5m, (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes' and outcome = 'not_modified')::int as not_modified_5m, (select count(*) from events where detected_at >= now() - interval '5 minutes')::int as events_5m, (select count(*) from changes where detected_at >= now() - interval '5 minutes')::int as changes_5m, (select count(*) from sensors where enabled and next_check_at <= now())::int as queue_due, (select avg(duration_ms)::int from sensor_runs where started_at >= now() - interval '5 minutes')::int as avg_latency_5m_ms, (select count(*) from changes where detected_at >= now() - interval '24 hours' and change_class in ('cosmetic','navigation','timestamp','advertisement','boilerplate'))::int as noise_filtered_24h`).then((r) => r.rows[0]),
248− // ---- Watchlists & alerts (anonymous owner token, phase 1) ---------------------------------- 322+ engineStatus(),
249− app.get("/api/v1/watchlists", async (req, reply) => { 323+ db.execute<Record<string, unknown>>(sql`select split_part(split_part(s.url, '/', 3), ':', 1) as host, count(*)::int as failures, max(r.error) as last_error from sensor_runs r join sensors s on s.id = r.sensor_id where r.started_at >= now() - interval '24 hours' and r.outcome in ('error','parse_error','rate_limited') group by 1 order by failures desc limit 15`).then((r) => r.rows),
250− const owner = ownerToken(req.headers as Record<string, unknown>); 324+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.avg_latency_ms, s.connector from sensors s where s.enabled and s.avg_latency_ms is not null order by s.avg_latency_ms desc limit 15`).then((r) => r.rows),
251− if (!owner) return reply.status(401).send({ error: "owner token required (X-WebSensor-Owner)" }); 325+ ]);
252− const rows = await db.execute<Record<string, unknown>>(sql`select w.*, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at)) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`); 326+ const t = throughput ?? {};
253− return { items: rows.rows }; 327+ return { connectors, sensors_by_health: sensorsByHealth, sensors_by_status: sensorsByStatus, degraded_sensors: worst, noisy_sensors: noisy, daily, sensors_by_connector: byConnectorSensors, throughput: { ...t, checks_per_min: Math.round(Number(t.checks_5m ?? 0) / 5), events_per_min: Math.round((Number(t.events_5m ?? 0) / 5) * 10) / 10, not_modified_ratio: Number(t.checks_5m) ? Math.round((Number(t.not_modified_5m) / Number(t.checks_5m)) * 100) / 100 : null }, engine: es, top_failing_domains: topFailingDomains, slowest_sensors: slowest, live: liveStats() };
254− }); 328+ }),
255− app.post<{ Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists", async (req, reply) => { 329+ );
256− const owner = ownerToken(req.headers as Record<string, unknown>);
257− if (!owner) return reply.status(401).send({ error: "owner token required" });
258− const body = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).default([]) }).parse(req.body ?? {});
259− const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n;
260− if (Number(count) >= 20) return reply.status(429).send({ error: "too many watchlists" });
261− const id = newId("wl");
262− await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`);
263− for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`);
264− return { id, name: body.name, items: body.items };
265− });
266− app.put<{ Params: { id: string }; Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists/:id", async (req, reply) => {
267− const owner = ownerToken(req.headers as Record<string, unknown>);
268− if (!owner) return reply.status(401).send({ error: "owner token required" });
269− const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0];
270− if (!w) return reply.status(404).send({ error: "not_found" });
271− const body = z.object({ name: z.string().min(1).max(80).optional(), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).optional() }).parse(req.body ?? {});
272− if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`);
273− if (body.items) {
274− await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`);
275− for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`);
276− }
277− return { ok: true };
278− });
279− app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => {
280− const owner = ownerToken(req.headers as Record<string, unknown>);
281− if (!owner) return reply.status(401).send({ error: "owner token required" });
282− await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`);
283− return { ok: true };
284− });
285− app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => {
286− const owner = ownerToken(req.headers as Record<string, unknown>);
287− if (!owner) return reply.status(401).send({ error: "owner token required" });
288− const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows;
289− if (!items.length) return reply.send({ items: [] });
290− const ents = items.filter((i) => i.kind === "entity").map((i) => i.value);
291− const srcs = items.filter((i) => i.kind === "source").map((i) => i.value);
292− const cats = items.filter((i) => i.kind === "category").map((i) => i.value);
293− const kws = items.filter((i) => i.kind === "keyword").map((i) => i.value);
294− const conds = [] as ReturnType<typeof sql>[];
295− if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`);
296− if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`);
297− if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`);
298− for (const k of kws) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`);
299− if (!conds.length) return { items: [] };
300− const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` or `)} order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
301− return { items: rows.rows };
302− });
303−
304− app.get("/api/v1/alerts", async (req, reply) => {
305− const owner = ownerToken(req.headers as Record<string, unknown>);
306− if (!owner) return reply.status(401).send({ error: "owner token required" });
307− return { items: (await db.execute<Record<string, unknown>>(sql`select * from alerts where owner_token = ${owner} order by created_at`)).rows };
308− });
309− app.post<{ Body: Record<string, unknown> }>("/api/v1/alerts", async (req, reply) => {
310− const owner = ownerToken(req.headers as Record<string, unknown>);
311− if (!owner) return reply.status(401).send({ error: "owner token required" });
312− const body = z.object({ name: z.string().min(1).max(80), rule: z.object({ importance_min: z.number().min(0).max(100).optional(), event_types: z.array(z.string()).optional(), entities: z.array(z.string()).optional(), sources: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), silent_only: z.boolean().optional(), categories: z.array(z.string()).optional() }), channel: z.enum(["web"]).default("web") }).parse(req.body ?? {});
313− const id = newId("alr");
314− await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel})`);
315− return { id, ...body };
316− });
317− app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => {
318− const owner = ownerToken(req.headers as Record<string, unknown>);
319− if (!owner) return reply.status(401).send({ error: "owner token required" });
320− await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`);
321− return { ok: true };
322− });
323 330
324 // ---- Machine-readable feeds ------------------------------------------------------------------ 331 // ---- Machine-readable feeds ------------------------------------------------------------------
325− app.get<{ Querystring: { category?: string; importance_min?: string; silent_change?: string } }>("/api/v1/feed.rss", async (req, reply) => { 332+ app.get("/api/v1/feed.rss", async (req, reply) => {
326− const q = eventsQuery.parse({ ...req.query, limit: 50 }); 333+ const q = eventsQuery.parse({ ...(req.query as Record<string, unknown>), limit: 50 });
327 const { items } = await listEvents(q); 334 const { items } = await listEvents(q);
328 const esc = (s: unknown): string => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 335 const esc = (s: unknown): string => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
329 const base = config.publicBaseUrl; 336 const base = config.publicBaseUrl;
330− const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items 337+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : q.silent_change ? "silent changes" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items
331− .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}) — source: ${esc(e.url)}</description></item>`) 338+ .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (signal ${esc(e.signal_score ?? e.importance)}, importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}${e.first_party ? ", first-party" : ""}) — source: ${esc(e.url)}</description></item>`)
332 .join("")}</channel></rss>`; 339 .join("")}</channel></rss>`;
333 reply.header("content-type", "application/rss+xml; charset=utf-8"); 340 reply.header("content-type", "application/rss+xml; charset=utf-8");
334 return reply.send(xml); 341 return reply.send(xml);
@@ -337,13 +344,59 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
337 app.get("/api/v1", async () => ({ 344 app.get("/api/v1", async () => ({
338 name: "WebSensor API", 345 name: "WebSensor API",
339 version: "v1", 346 version: "v1",
347+ release: config.version,
340 docs: `${config.publicBaseUrl}/api`, 348 docs: `${config.publicBaseUrl}/api`,
341− endpoints: ["/api/v1/events", "/api/v1/events/{id|slug}", "/api/v1/changes/{id}", "/api/v1/snapshots/{id}", "/api/v1/snapshots/compare?a=&b=", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/sensors/{id}", "/api/v1/entities", "/api/v1/entities/{id}", "/api/v1/entities/{id}/timeline", "/api/v1/domains/{domain}/timeline", "/api/v1/urls/history?url=", "/api/v1/search?q=", "/api/v1/stats", "/api/v1/trending", "/api/v1/explore", "/api/v1/clusters", "/api/v1/health/connectors", "/api/v1/watchlists", "/api/v1/alerts", "/api/v1/feed.rss", "wss://…/api/v1/live"], 349+ rate_limit: "600 requests / minute / IP (headers x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset)",
350+ endpoints: [
351+ "/api/v1/events",
352+ "/api/v1/events/count",
353+ "/api/v1/events/{id|slug}",
354+ "/api/v1/changes/{id}",
355+ "/api/v1/snapshots/{id}",
356+ "/api/v1/snapshots/compare?a=&b=",
357+ "/api/v1/sensors/{id}",
358+ "/api/v1/sensors/{id}/snapshots",
359+ "/api/v1/sources",
360+ "/api/v1/sources/{id}",
361+ "/api/v1/entities",
362+ "/api/v1/entities/rank",
363+ "/api/v1/entities/{id}",
364+ "/api/v1/entities/{id}/timeline",
365+ "/api/v1/clusters",
366+ "/api/v1/clusters/{id|slug}",
367+ "/api/v1/domains/{domain}/timeline",
368+ "/api/v1/urls/history?url=",
369+ "/api/v1/search?q=",
370+ "/api/v1/stats",
371+ "/api/v1/trending",
372+ "/api/v1/breaking",
373+ "/api/v1/pulse",
374+ "/api/v1/radar",
375+ "/api/v1/explore",
376+ "/api/v1/countries",
377+ "/api/v1/countries/{code|slug}",
378+ "/api/v1/categories/{channel}",
379+ "/api/v1/health/connectors",
380+ "/api/v1/watchlists",
381+ "/api/v1/alerts",
382+ "/api/v1/notifications",
383+ "/api/v1/bookmarks",
384+ "/api/v1/views",
385+ "/api/v1/monitors",
386+ "/api/v1/feed.rss",
387+ "wss://…/api/v1/live",
388+ ],
342 })); 389 }));
343 } 390 }
344 391
392+function stripEmpty(o: Record<string, unknown>): Record<string, unknown> {
393+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ""));
394+}
395+
345 function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string { 396 function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string {
346 if (c.mode === "text") return c.text ?? ""; 397 if (c.mode === "text") return c.text ?? "";
347 if (c.mode === "list") return (c.items ?? []).map((i) => [i.title ?? i.url ?? i.key, i.url && i.title ? i.url : null, i.summary ? String(i.summary).slice(0, 300) : null].filter(Boolean).join(" — ")).join("\n"); 398 if (c.mode === "list") return (c.items ?? []).map((i) => [i.title ?? i.url ?? i.key, i.url && i.title ? i.url : null, i.summary ? String(i.summary).slice(0, 300) : null].filter(Boolean).join(" — ")).join("\n");
348 return JSON.stringify(c.json ?? null, null, 2); 399 return JSON.stringify(c.json ?? null, null, 2);
349 } 400 }
401+
402+export { textArray };
modified apps/api/src/server.ts +9 −2
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/alerts.ts +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/cli.ts +18 −1
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/cluster.ts +78 −22
Large diff (180 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/config.ts +6 −2
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/engine.test.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/entities.ts +79 −3
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/index.ts +8 −1
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/metrics.ts +20 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/pipeline.ts +142 −30
Large diff (368 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/redis.ts +19 −4
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/registry.ts +46 −6
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/retention.ts +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/scheduler.ts +41 −2
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
modified apps/engine/src/seeds.ts +6 −52
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/alerts/alerts.tsx +362 −71
Large diff (528 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/alerts/page.tsx +7 −4
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/api/page.tsx +144 −62
Large diff (270 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/bookmarks/bookmarks.tsx +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/bookmarks/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/breaking/loading.tsx +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/breaking/page.tsx +67 −6
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/category/[channel]/loading.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/category/[channel]/page.tsx +111 −10
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/cluster/[slug]/layout.tsx +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/cluster/[slug]/loading.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/cluster/[slug]/page.tsx +240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/company/[id]/page.tsx +10 −106
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/country/[slug]/loading.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/country/[slug]/page.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/country/loading.tsx +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/country/page.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entities/loading.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/entities/page.tsx +70 −11
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entity/[id]/layout.tsx +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entity/[id]/loading.tsx +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entity/[id]/page.tsx +480 −0
Large diff (480 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/error.tsx +26 −7
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/event/[slug]/loading.tsx +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/event/[slug]/opengraph-image.tsx +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/event/[slug]/page.tsx +248 −113
Large diff (475 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/explore/loading.tsx +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/explore/page.tsx +373 −98
Large diff (485 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/globals.css +103 −10
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/health/loading.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/health/page.tsx +122 −14
Large diff (175 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/layout.tsx +18 −9
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/live/loading.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/live/page.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/monitors/monitors.tsx +310 −0
Large diff (310 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/monitors/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/not-found.tsx +19 −8
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/ops/ops.tsx +706 −0
Large diff (706 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/ops/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/page.tsx +35 −10
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/pulse/loading.tsx +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/pulse/page.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/radar/loading.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/radar/page.tsx +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/search/page.tsx +134 −56
Large diff (208 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sensor/[id]/loading.tsx +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/sensor/[id]/page.tsx +165 −73
Large diff (267 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/silent/loading.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/silent/page.tsx +44 −5
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/sitemap.ts +27 −6
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/source/[id]/loading.tsx +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/source/[id]/page.tsx +190 −51
Large diff (317 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sources/loading.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/sources/page.tsx +58 −21
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/timeline/[id]/page.tsx +7 −28
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/watchlists/page.tsx +2 −2
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/watchlists/watchlists.tsx +210 −45
Large diff (337 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/command-palette.tsx +205 −0
Large diff (205 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/event-drawer.tsx +330 −0
Large diff (330 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/event-row.tsx +68 −22
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/field-changes.tsx +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/live-feed.tsx +196 −38
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/live-strip.tsx +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/nav.tsx +78 −52
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/prefs.tsx +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/rail.tsx +108 −17
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui.tsx +194 −35
Large diff (290 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/api.ts +331 −10
Large diff (499 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/feed-filters.ts +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/format.ts +83 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/use-live.ts +30 −6
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.d/40-ai-frontier.yaml +1129 −0
Large diff (1,129 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.d/41-cloud-infrastructure.yaml +994 −0
Large diff (994 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.d/42-cybersecurity.yaml +1770 −0
Large diff (1,770 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.d/43-finance-markets.yaml +1278 −0
Large diff (1,278 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.d/44-governments.yaml +1660 −0
Large diff (1,660 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme