SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%

WebSensor v0.1.0 — real-time web intelligence platform

Core (taxonomy, SSRF policy, canonical extraction, diff engines, heuristics, scoring, adaptive schedule),
db (SQL migrations + Drizzle), content-addressed zstd blob store, connectors (http, rss/atom, sitemap,
statuspage, github, jsonlist, discovery, scrapfly fallback), engine (scheduler, pipeline, Claude
interpretation, entities, novelty/clustering, silent changes, metrics), Fastify gateway (REST + WebSocket +
proxy), Next.js 16 frontend, 200-source registry, tests, docs, mld manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026)

142 changed files +18,939 −0

added .env.example +30 −0
@@ -0,0 +1,30 @@
1 +# WebSensor — environment (never commit real values)
2 +NODE_ENV=development
3 +DATABASE_URL=postgres://localhost:5432/websensor
4 +REDIS_URL=redis://127.0.0.1:6379
5 +
6 +# Content-addressed blob store (fs driver). S3/MinIO driver can be plugged in later.
7 +BLOB_STORE_DIR=./data/blobs
8 +
9 +PUBLIC_BASE_URL=http://localhost:8260
10 +API_PORT=8260
11 +API_HOST=127.0.0.1
12 +WEB_PORT=8261
13 +WEB_URL=http://127.0.0.1:8261
14 +ENGINE_METRICS_PORT=8262
15 +
16 +# Optional acquisition fallbacks
17 +FIRECRAWL_API_KEY=
18 +SCRAPFLY_API_KEY=
19 +
20 +# AI interpretation (heuristics run without any key)
21 +ANTHROPIC_API_KEY=
22 +WS_LLM_MODEL_FAST=claude-haiku-4-5
23 +WS_LLM_MODEL_DEEP=claude-opus-5
24 +WS_LLM_DAILY_CALL_BUDGET=600
25 +WS_LLM_MIN_IMPORTANCE=35
26 +
27 +# Engine tuning
28 +WS_FETCH_CONCURRENCY=16
29 +WS_USER_AGENT=WebSensorBot/0.1 (+https://www.websensor.io/bot)
30 +WS_SOURCES_FILE=./config/sources.yaml
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +*.tsbuildinfo
5 +.env
6 +.env.*
7 +!.env.example
8 +logs/
9 +tmp/
10 +coverage/
11 +.DS_Store
12 +.claude/
13 +deploy/*.mld.json
14 +data/
15 +qa/test-results/
16 +qa/.e2e-*
17 +apps/web/next-env.d.ts
added CLAUDE.md +76 −0
@@ -0,0 +1,76 @@
1 +# WebSensor — repository guide
2 +
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
5 +importance, preserves evidence and publishes events through a WebSocket feed. The product brief that drove the
6 +design is `docs/PRODUCT-BRIEF.md`; this file is the working guide for the code.
7 +
8 +## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15)
9 +- `packages/core` — taxonomy (event types + intrinsic severity, tiers, categories), ids, SSRF policy
10 + (`assertUrlAllowed`, `safeLookup`), hashing (sha256, simhash, shingles/Jaccard), canonical extraction
11 + (`canonicalizeHtml`: strips scripts/nav/footer/cookie chrome, timestamps, tokens, counters), diff engines
12 + (text / json / keyed list), stage-1 heuristics (`evaluateChange`, `describeChange`), scoring
13 + (importance components, confidence, novelty helpers, trending, activity anomaly), adaptive schedule.
14 + `@websensor/core/client` is the browser-safe subset (no `node:` imports) — client components must import it.
15 +- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) +
16 + Drizzle schema for typed access. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays
17 + (Drizzle spreads arrays into parameter lists).
18 +- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`, zstd via `node:zlib`). Raw
19 + bodies, canonical representations and full diffs live there, never in Postgres. Interface ready for S3/MinIO.
20 +- `packages/connectors` — connector SDK (`WebSensorConnector.fetch/normalize`), safe fetcher (conditional GET,
21 + manual redirects validated per hop, HTTP/2→1.1 pin on NGHTTP2 errors, browser-UA second attempt on
22 + 403/resets, size + time limits), connectors: `http` (HTML/JSON/text/HEAD), `rss` (RSS/Atom/RDF/JSON Feed),
23 + `sitemap` (index + news), `statuspage` (Atlassian v2 summary), `github` (releases/tags/commits Atom,
24 + advisories REST), `jsonlist` (keyed records from any JSON API; `{now-2h}` placeholders), `discovery`
25 + (robots sitemaps, `<link rel=alternate>`, well-known feed paths, linked status pages — every candidate is
26 + fetched and parsed), `scrapfly` (fallback, budgeted, only when `fallback.scrapfly` is set on the source).
27 +- `apps/engine` — scheduler (`FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency), pipeline
28 + (fetch → normalize → snapshot → diff → heuristics → change → event: entities, novelty, LLM interpretation,
29 + clustering, importance/confidence, silent-change detection, publish to Redis), registry sync from
30 + `config/sources.yaml`, discovery, connector health rollups, Prometheus metrics on :8262. CLI: `src/cli.ts`
31 + (`sync`, `discover`, `probe <domain>`, `run-once <sensor>`, `run-due [n]`).
32 +- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*`, WebSocket `/api/v1/live` (Redis pub/sub fan-out,
33 + channels `events:*`, `entity:*`, `source:*`, `watchlist:*`), `/api/v1/feed.rss`, `/api/health` `/api/ready` `/api/metrics`, apex→www
34 + redirect, and a reverse proxy to the Next.js app for everything else.
35 +- `apps/web` — Next.js 16 frontend (:8261, loopback). Pages: live, breaking, explore, sources, entities,
36 + timelines, silent changes, watchlists, alerts, event detail with diff viewer, health, API docs.
37 +- `config/sources.yaml` — the 200-organization registry (sources, curated sensors, product entities, discovery
38 + flags, fallbacks). `docs/connectors/*.md` document each connector family.
39 +
40 +## Rules
41 +- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets — goes through
42 + `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net` and single-label hosts are
43 + blocked; the dispatcher's DNS lookup only returns approved addresses.
44 +- Raw evidence is immutable: snapshots and diffs are never rewritten. Re-interpretation creates a new row in
45 + `interpretations` (versioned); `events.interpretation` holds the latest.
46 +- LLM cost control: heuristics first; Claude is called only for candidates above `WS_LLM_MIN_IMPORTANCE`, with a
47 + daily call budget; the deep model only above `WS_LLM_DEEP_MIN_IMPORTANCE`. Output is strict JSON
48 + (`output_config.format`), never free text. No key → heuristics only, the system still works.
49 +- Feeds: items are "new" only if their key was never seen (sensor `state.seenKeys`) and they are not older than
50 + 14 days; items scrolling out of the window are never "removed". Sitemaps/statuspages: a >50 % shrink is
51 + treated as a partial response, not mass deletion. A 404 becomes `page_removed` only after
52 + `WS_DELETE_CONFIRMATIONS` checks separated by `WS_DELETE_SEPARATION_MIN`.
53 +- Never label inference as fact: events carry `evidence_label` (OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED)
54 + and the interpretation keeps `observed` and `inferred` apart. Silent changes are flagged, never asserted as
55 + "unannounced" without checking recent announcement-type events of the same source.
56 +- Do not add sensors that are blocked with 403 by design (Akamai/Cloudflare bot management) unless the source
57 + has `fallback.scrapfly: true` and a tier ≥ C; Scrapfly is a budgeted fallback, not the foundation.
58 +- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`…).
59 +
60 +## Dev
61 +```
62 +createdb websensor && cp .env.example .env
63 +pnpm install
64 +pnpm db:migrate # or let the engine migrate on start
65 +npx tsx apps/engine/src/cli.ts sync # registry → DB
66 +npx tsx apps/engine/src/cli.ts run-due 50
67 +pnpm dev:api · pnpm dev:engine · pnpm dev:web
68 +pnpm test · pnpm typecheck
69 +```
70 +Run CLI/engine from the repo root (config paths are relative to cwd).
71 +
72 +## Deploy (MacLustr)
73 +`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `deploy/websensor.mld.json` (secrets only
74 +on M1M32). Processes: `websensor-api` (:8260, ngrok www.websensor.io), `websensor-web` (:8261 loopback),
75 +`websensor-engine` (metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`.
76 +See `deploy/README.md`.
added README.md +33 −0
@@ -0,0 +1,33 @@
1 +# WebSensor
2 +
3 +**Detect What Changed. Know Why It Matters.** — https://www.websensor.io
4 +
5 +WebSensor is a global sensor network for the changing Web. It continuously monitors official public sources
6 +(newsrooms, status pages, changelogs, regulators, registries, APIs), preserves every observation, detects raw
7 +changes, filters noise, interprets meaningful changes into events, links them to entities, scores their
8 +importance and confidence, clusters related events and publishes them live over WebSocket.
9 +
10 +```
11 +Sources → Sensors/Connectors → Fetch (conditional) → Snapshot (immutable) → Diff → Heuristics
12 + → Interpretation (Claude, budgeted) → Entities → Novelty/Clustering → Importance → Event
13 + → Redis stream → WebSocket gateway → www.websensor.io
14 +```
15 +
16 +- 200 organizations, ~290 curated sensors at launch (`config/sources.yaml`), plus validated discovery.
17 +- Connector families: HTTP (HTML/JSON/HEAD), RSS/Atom/JSON Feed, sitemaps, Statuspage, GitHub, JSON APIs
18 + (CISA KEV, NVD, Federal Register, USGS, HIBP, Google Cloud incidents…), Scrapfly fallback.
19 +- Every event is auditable: event → change → snapshot A/B → fetch run → sensor → source, with raw evidence.
20 +- Observed vs inferred is always labelled; silent (unannounced) changes are flagged.
21 +
22 +## Quick start
23 +```
24 +createdb websensor && cp .env.example .env && pnpm install
25 +npx tsx apps/engine/src/cli.ts sync # load the registry
26 +pnpm dev:api & pnpm dev:web & pnpm dev:engine
27 +open http://localhost:8260
28 +```
29 +
30 +Docs: `CLAUDE.md` (repo guide), `docs/ARCHITECTURE.md`, `docs/connectors/*.md`, `deploy/README.md`,
31 +API docs at `/api` on the site. Public API: `GET /api/v1/events`, `/api/v1/entities/{id}`,
32 +`/api/v1/sources/{id}`, `/api/v1/domains/{domain}/timeline`, `/api/v1/changes/{id}`, `wss://…/api/v1/live`,
33 +`/api/v1/feed.rss`.
added apps/api/package.json +35 −0
@@ -0,0 +1,35 @@
1 +{
2 + "name": "@websensor/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/server.ts",
8 + "start": "tsx src/server.ts",
9 + "typecheck": "tsc -p tsconfig.json --noEmit",
10 + "test": "vitest run --passWithNoTests"
11 + },
12 + "dependencies": {
13 + "@fastify/cors": "^11.0.0",
14 + "@fastify/rate-limit": "^10.3.0",
15 + "@fastify/reply-from": "^12.6.5",
16 + "@fastify/websocket": "^11.2.0",
17 + "@websensor/core": "workspace:*",
18 + "@websensor/db": "workspace:*",
19 + "@websensor/store": "workspace:*",
20 + "fastify": "^5.4.0",
21 + "ioredis": "^5.6.0",
22 + "pino": "^9.7.0",
23 + "pino-pretty": "^13.0.0",
24 + "prom-client": "^15.1.0",
25 + "tsx": "^4.20.0",
26 + "ws": "^8.21.3",
27 + "zod": "^4.0.0"
28 + },
29 + "devDependencies": {
30 + "@types/node": "^24.0.0",
31 + "@types/ws": "^8.18.1",
32 + "typescript": "^5.9.3",
33 + "vitest": "^3.2.0"
34 + }
35 +}
added apps/api/src/config.ts +14 −0
@@ -0,0 +1,14 @@
1 +const env = process.env;
2 +
3 +export const config = {
4 + env: env.NODE_ENV ?? "development",
5 + logLevel: env.LOG_LEVEL ?? "info",
6 + port: Number(env.API_PORT ?? 8260),
7 + host: env.API_HOST ?? "127.0.0.1",
8 + redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
9 + webUrl: env.WEB_URL ?? "http://127.0.0.1:8261",
10 + publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260",
11 + canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io",
12 + redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0",
13 + version: "0.1.0",
14 +};
added apps/api/src/live.ts +129 −0
@@ -0,0 +1,129 @@
1 +import type { FastifyInstance } from "fastify";
2 +import type { WebSocket } from "ws";
3 +import Redis from "ioredis";
4 +import { FEED_CHANNELS } from "@websensor/core";
5 +import { db, sql } from "@websensor/db";
6 +import { config } from "./config";
7 +
8 +/**
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|
11 + * science|products|infrastructure> · entity:<id> · source:<id> · watchlist:<id>.
12 + * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1}
13 + * server → {"type":"hello"} | {"type":"event", "channels":[…], "event":{…}} | {"type":"pong"}
14 + */
15 +interface Client {
16 + ws: WebSocket;
17 + channels: Set<string>;
18 + watchlists: Map<string, { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }>;
19 +}
20 +
21 +const clients = new Set<Client>();
22 +let sub: Redis | null = null;
23 +let published = 0;
24 +
25 +export function liveStats(): { clients: number; published: number } {
26 + return { clients: clients.size, published };
27 +}
28 +
29 +export async function registerLive(app: FastifyInstance): Promise<void> {
30 + sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 });
31 + sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error"));
32 + await sub.subscribe("ws:live");
33 + sub.on("message", (_ch, msg) => {
34 + let ev: Record<string, unknown>;
35 + try {
36 + ev = JSON.parse(msg) as Record<string, unknown>;
37 + } catch {
38 + return;
39 + }
40 + published++;
41 + const chans = channelsFor(ev);
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 + });
49 +
50 + app.get("/api/v1/live", { websocket: true }, (socket) => {
51 + const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() };
52 + clients.add(client);
53 + socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString() }));
54 + socket.on("message", async (raw: Buffer | string) => {
55 + let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number };
56 + try {
57 + msg = JSON.parse(raw.toString()) as typeof msg;
58 + } catch {
59 + return;
60 + }
61 + if (msg.ping) socket.send(JSON.stringify({ type: "pong", t: Date.now() }));
62 + for (const ch of msg.subscribe ?? []) {
63 + if (typeof ch !== "string" || ch.length > 120 || client.channels.size > 64) continue;
64 + if (ch.startsWith("watchlist:")) await loadWatchlist(client, ch.slice(10));
65 + else client.channels.add(ch);
66 + }
67 + for (const ch of msg.unsubscribe ?? []) {
68 + client.channels.delete(ch);
69 + if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10));
70 + }
71 + socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] }));
72 + });
73 + const hb = setInterval(() => {
74 + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() }));
75 + }, 25_000);
76 + socket.on("close", () => {
77 + clearInterval(hb);
78 + clients.delete(client);
79 + });
80 + socket.on("error", () => {
81 + clearInterval(hb);
82 + clients.delete(client);
83 + });
84 + });
85 +}
86 +
87 +export function channelsFor(ev: Record<string, unknown>): Set<string> {
88 + const out = new Set<string>(["events:global"]);
89 + const importance = Number(ev.importance ?? 0);
90 + if (importance >= 80) out.add("events:breaking");
91 + if (ev.silent) out.add("events:silent");
92 + 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}`);
94 + const src = ev.source as { id?: string } | undefined;
95 + if (src?.id) out.add(`source:${src.id}`);
96 + for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`);
97 + out.add(`type:${String(ev.type)}`);
98 + return out;
99 +}
100 +
101 +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}`);
103 + const w = { entities: new Set<string>(), sources: new Set<string>(), keywords: [] as string[], categories: new Set<string>() };
104 + for (const r of rows.rows) {
105 + if (r.kind === "entity") w.entities.add(r.value);
106 + else if (r.kind === "source") w.sources.add(r.value);
107 + else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase());
108 + else if (r.kind === "category") w.categories.add(r.value);
109 + }
110 + client.watchlists.set(id, w);
111 +}
112 +
113 +function matchesWatchlist(ev: Record<string, unknown>, w: { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }): boolean {
114 + const src = ev.source as { id?: string } | undefined;
115 + 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;
117 + for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true;
118 + if (w.keywords.length) {
119 + const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase();
120 + if (w.keywords.some((k) => hay.includes(k))) return true;
121 + }
122 + return false;
123 +}
124 +
125 +export async function closeLive(): Promise<void> {
126 + for (const c of clients) c.ws.close(1001, "server shutdown");
127 + clients.clear();
128 + if (sub) await sub.quit().catch(() => undefined);
129 +}
added apps/api/src/queries.ts +146 −0
@@ -0,0 +1,146 @@
1 +import { db, sql, textArray } from "@websensor/db";
2 +
3 +/** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */
4 +
5 +export interface EventFilters {
6 + after?: string;
7 + before?: string;
8 + category?: string;
9 + entity?: string;
10 + source?: string;
11 + domain?: string;
12 + sensor?: string;
13 + cluster?: string;
14 + importance_min?: number;
15 + confidence_min?: number;
16 + event_type?: string;
17 + silent_change?: boolean;
18 + q?: string;
19 + limit: number;
20 + cursor?: string;
21 + order?: "recent" | "importance";
22 +}
23 +
24 +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,
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,
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,
28 + 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,
30 + 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)
32 + 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`;
34 +
35 +export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> {
36 + const conds = [sql`true`];
37 + 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)}`);
39 + if (f.category) conds.push(sql`${f.category} = any(e.categories)`);
40 + if (f.source) conds.push(sql`e.source_id = ${f.source}`);
41 + if (f.sensor) conds.push(sql`e.sensor_id = ${f.sensor}`);
42 + if (f.cluster) conds.push(sql`e.cluster_id = ${f.cluster}`);
43 + if (f.domain) conds.push(sql`s.domain = ${f.domain}`);
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})`);
45 + 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}`);
47 + if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(","))})`);
48 + 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})`);
50 + if (f.cursor) {
51 + const [ts, id] = decodeCursor(f.cursor);
52 + if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`);
53 + else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`);
54 + }
55 + const order = f.order === "importance" ? sql`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}`);
57 + const items = rows.rows.slice(0, f.limit);
58 + 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;
60 + return { items, nextCursor };
61 +}
62 +
63 +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`);
65 + return rows.rows[0] ?? null;
66 +}
67 +
68 +export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> {
69 + 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
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)}))
72 + order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`);
73 + return rows.rows;
74 +}
75 +
76 +export async function stats(): Promise<Record<string, unknown>> {
77 + const [r] = (
78 + await db.execute<Record<string, unknown>>(sql`
79 + select
80 + (select count(*) from sources where enabled) as sources,
81 + (select count(*) from sensors where enabled) as sensors,
82 + (select count(*) from entities) as entities,
83 + (select count(*) from snapshots) as snapshots,
84 + (select count(*) from events) as events_total,
85 + (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h,
86 + (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,
88 + (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,
90 + (select coalesce(sum(not_modified),0) from metrics_daily where day = current_date) as not_modified_today,
91 + (select coalesce(sum(bytes),0) from metrics_daily where day = current_date) as bytes_today,
92 + (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour,
93 + (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,
95 + (select max(started_at) from sensor_runs) as last_check_at,
96 + (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,
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`)
99 + ).rows;
100 + return Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v]));
101 +}
102 +
103 +export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> {
104 + const rows = await db.execute<Record<string, unknown>>(sql`
105 + 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
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),
108 + prev as (
109 + 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)
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,
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
113 + from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id
114 + order by score desc limit ${limit}`);
115 + return rows.rows;
116 +}
117 +
118 +export async function sourceActivity(sourceId: string): Promise<Record<string, unknown>> {
119 + const [r] = (
120 + await db.execute<Record<string, unknown>>(sql`
121 + select
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,
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,
124 + (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`)
126 + ).rows;
127 + const c2h = Number(r?.changes_2h ?? 0);
128 + const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24);
129 + const currentPerHour = c2h / 2;
130 + let anomaly = 0;
131 + if (baselinePerHour <= 0) anomaly = currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0;
132 + else {
133 + const ratio = currentPerHour / baselinePerHour;
134 + anomaly = ratio <= 1 ? ratio * 30 : Math.min(100, 30 + 25 * Math.log2(ratio));
135 + }
136 + return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 };
137 +}
138 +
139 +export function encodeCursor(a: string, b: string): string {
140 + return Buffer.from(`${a}|${b}`).toString("base64url");
141 +}
142 +export function decodeCursor(c: string): [string, string] {
143 + const s = Buffer.from(c, "base64url").toString("utf8");
144 + const i = s.indexOf("|");
145 + return [s.slice(0, i), s.slice(i + 1)];
146 +}
added apps/api/src/routes.ts +349 −0
@@ -0,0 +1,349 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { z } from "zod";
3 +import { diffText, EVENT_TYPES, FEED_CHANNELS, newId } from "@websensor/core";
4 +import { db, sql, textArray } from "@websensor/db";
5 +import { getBlobStore } from "@websensor/store";
6 +import { config } from "./config";
7 +import { liveStats } from "./live";
8 +import { getEvent, listEvents, relatedEvents, sourceActivity, stats, trending, EVENT_SELECT } from "./queries";
9 +
10 +const eventsQuery = z.object({
11 + after: z.string().optional(),
12 + before: z.string().optional(),
13 + category: z.string().optional(),
14 + entity: z.string().optional(),
15 + source: z.string().optional(),
16 + domain: z.string().optional(),
17 + sensor: z.string().optional(),
18 + cluster: z.string().optional(),
19 + importance_min: z.coerce.number().min(0).max(100).optional(),
20 + confidence_min: z.coerce.number().min(0).max(100).optional(),
21 + event_type: z.string().optional(),
22 + silent_change: z
23 + .enum(["true", "false"])
24 + .transform((v) => v === "true")
25 + .optional(),
26 + q: z.string().max(200).optional(),
27 + limit: z.coerce.number().int().min(1).max(200).default(50),
28 + cursor: z.string().optional(),
29 + order: z.enum(["recent", "importance"]).default("recent"),
30 +});
31 +
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> {
38 + // ---- Health ---------------------------------------------------------------------------
39 + app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() }));
40 + app.get("/api/ready", async (_req, reply) => {
41 + const checks: Record<string, boolean | number> = {};
42 + try {
43 + await db.execute(sql`select 1`);
44 + checks.database = true;
45 + } catch {
46 + checks.database = false;
47 + }
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'`);
49 + checks.engine_recent = Boolean(engine.rows[0]?.last);
50 + checks.ws_clients = liveStats().clients;
51 + return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks });
52 + });
53 +
54 + // ---- Events ---------------------------------------------------------------------------
55 + app.get("/api/v1/events", async (req) => {
56 + const q = eventsQuery.parse(req.query);
57 + return listEvents(q);
58 + });
59 + app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => {
60 + const ev = await getEvent(req.params.id);
61 + if (!ev) return reply.status(404).send({ error: "not_found" });
62 + const [related, cluster, change, interp] = await Promise.all([
63 + 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,
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,
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),
67 + ]);
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 ?? "")})`);
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`);
70 + return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null };
71 + });
72 +
73 + // ---- Changes / snapshots / diffs -------------------------------------------------------
74 + app.get<{ Params: { id: string } }>("/api/v1/changes/:id", async (req, reply) => {
75 + const r = await db.execute<Record<string, unknown>>(sql`select c.*, s.url as sensor_url, s.name as sensor_name, s.source_id from changes c join sensors s on s.id = c.sensor_id where c.id = ${req.params.id}`);
76 + const c = r.rows[0];
77 + if (!c) return reply.status(404).send({ error: "not_found" });
78 + let unified: string | null = null;
79 + if (c.diff_storage_key) unified = await getBlobStore().getText(String(c.diff_storage_key)).catch(() => null);
80 + return { change: c, unified };
81 + });
82 + app.get<{ Params: { id: string }; Querystring: { raw?: string } }>("/api/v1/snapshots/:id", async (req, reply) => {
83 + const r = await db.execute<Record<string, unknown>>(sql`select * from snapshots where id = ${req.params.id}`);
84 + const s = r.rows[0];
85 + if (!s) return reply.status(404).send({ error: "not_found" });
86 + const store = getBlobStore();
87 + if (req.query.raw === "1" && s.storage_key) {
88 + 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"));
90 + reply.header("x-content-type-options", "nosniff");
91 + reply.header("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:");
92 + return reply.send(buf);
93 + }
94 + const canonical = s.canonical_storage_key ? await store.getText(String(s.canonical_storage_key)).catch(() => null) : null;
95 + return { snapshot: s, canonical: canonical ? JSON.parse(canonical) : null };
96 + });
97 + app.get<{ Querystring: { a: string; b: string } }>("/api/v1/snapshots/compare", async (req, reply) => {
98 + const { a, b } = req.query;
99 + if (!a || !b) return reply.status(400).send({ error: "a and b required" });
100 + const rows = await db.execute<Record<string, unknown>>(sql`select id, captured_at, canonical_storage_key, mode, url, sensor_id from snapshots where id in (${a}, ${b})`);
101 + const sa = rows.rows.find((r) => r.id === a);
102 + const sb = rows.rows.find((r) => r.id === b);
103 + if (!sa || !sb) return reply.status(404).send({ error: "not_found" });
104 + const store = getBlobStore();
105 + const [ca, cb] = await Promise.all([store.getText(String(sa.canonical_storage_key)), store.getText(String(sb.canonical_storage_key))]);
106 + const ta = renderCanonical(JSON.parse(ca));
107 + const tb = renderCanonical(JSON.parse(cb));
108 + 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 } };
110 + });
111 +
112 + // ---- Sources & sensors ------------------------------------------------------------------
113 + app.get<{ Querystring: { category?: string; q?: string; limit?: string } }>("/api/v1/sources", async (req) => {
114 + const cat = req.query.category;
115 + const q = req.query.q;
116 + 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,
118 + (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,
120 + (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,
122 + (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``}
124 + order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(500, Number(req.query.limit ?? 300))}`);
125 + return { items: rows.rows };
126 + });
127 + 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];
129 + if (!s) return reply.status(404).send({ error: "not_found" });
130 + const [sensors, ents, activity, candidates] = await Promise.all([
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),
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),
133 + sourceActivity(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),
135 + ]);
136 + return { source: s, sensors, entities: ents, activity, discovery: candidates };
137 + });
138 + 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];
140 + if (!s) return reply.status(404).send({ error: "not_found" });
141 + const [runs, snaps, changes] = 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),
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),
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),
145 + ]);
146 + const { etag: _e, last_modified: _lm, state: _st, ...pub } = s as Record<string, unknown>;
147 + return { sensor: pub, runs, snapshots: snaps, changes };
148 + });
149 +
150 + // ---- Entities ----------------------------------------------------------------------------
151 + app.get<{ Querystring: { type?: string; q?: string; limit?: string } }>("/api/v1/entities", async (req) => {
152 + 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
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``}
155 + order by en.event_count desc, en.importance desc, en.name limit ${Math.min(500, Number(req.query.limit ?? 200))}`);
156 + return { items: rows.rows };
157 + });
158 + 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];
160 + if (!e) return reply.status(404).send({ error: "not_found" });
161 + const id = String(e.id);
162 + const [children, relations, sources, aliases, recent, byType] = 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),
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),
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),
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)),
167 + 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),
169 + ]);
170 + return { entity: e, children, relations, sources, aliases, recent: recent.items, by_type: byType };
171 + });
172 + app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: 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}`;
174 + return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor });
175 + });
176 +
177 + // ---- Domains / URLs ----------------------------------------------------------------------
178 + app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => {
179 + 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];
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`);
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 };
183 + return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor };
184 + });
185 + app.get<{ Querystring: { url: string } }>("/api/v1/urls/history", async (req, reply) => {
186 + 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];
188 + 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
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`);
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`);
192 + return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows };
193 + });
194 +
195 + // ---- Explore / trending / stats ------------------------------------------------------------
196 + app.get("/api/v1/stats", async () => 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))) }));
198 + app.get("/api/v1/explore", async () => {
199 + const [mostActive, biggest, silent, clusters, unusual, byType, byCategory] = await Promise.all([
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),
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 + });
215 + app.get<{ Querystring: { limit?: string; since?: string } }>("/api/v1/clusters", async (req) => {
216 + const rows = await db.execute<Record<string, unknown>>(sql`
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
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))}`);
219 + return { items: rows.rows };
220 + });
221 +
222 + // ---- Search ----------------------------------------------------------------------------
223 + app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => {
224 + const q = (req.query.q ?? "").trim();
225 + if (q.length < 2) return { query: q, events: [], entities: [], sources: [], urls: [] };
226 + const lim = Math.min(50, Number(req.query.limit ?? 20));
227 + const [ev, ents, srcs, urls] = await Promise.all([
228 + listEvents({ q, limit: lim }).then((r) => r.items),
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),
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),
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),
232 + ]);
233 + return { query: q, events: ev, entities: ents, sources: srcs, urls };
234 + });
235 +
236 + // ---- Connector health ----------------------------------------------------------------------
237 + app.get("/api/v1/health/connectors", async () => {
238 + const [connectors, sensorsByHealth, worst, noisy, daily] = await Promise.all([
239 + db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows),
240 + db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).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),
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),
243 + db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows),
244 + ]);
245 + return { connectors, sensors_by_health: sensorsByHealth, degraded_sensors: worst, noisy_sensors: noisy, daily, live: liveStats() };
246 + });
247 +
248 + // ---- Watchlists & alerts (anonymous owner token, phase 1) ----------------------------------
249 + app.get("/api/v1/watchlists", async (req, reply) => {
250 + const owner = ownerToken(req.headers as Record<string, unknown>);
251 + if (!owner) return reply.status(401).send({ error: "owner token required (X-WebSensor-Owner)" });
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`);
253 + return { items: rows.rows };
254 + });
255 + app.post<{ Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists", async (req, reply) => {
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 +
324 + // ---- Machine-readable feeds ------------------------------------------------------------------
325 + app.get<{ Querystring: { category?: string; importance_min?: string; silent_change?: string } }>("/api/v1/feed.rss", async (req, reply) => {
326 + const q = eventsQuery.parse({ ...req.query, limit: 50 });
327 + 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;");
329 + 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
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>`)
332 + .join("")}</channel></rss>`;
333 + reply.header("content-type", "application/rss+xml; charset=utf-8");
334 + return reply.send(xml);
335 + });
336 +
337 + app.get("/api/v1", async () => ({
338 + name: "WebSensor API",
339 + version: "v1",
340 + 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"],
342 + }));
343 +}
344 +
345 +function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string {
346 + 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");
348 + return JSON.stringify(c.json ?? null, null, 2);
349 +}
added apps/api/src/server.ts +107 −0
@@ -0,0 +1,107 @@
1 +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
2 +import cors from "@fastify/cors";
3 +import rateLimit from "@fastify/rate-limit";
4 +import websocket from "@fastify/websocket";
5 +import replyFrom from "@fastify/reply-from";
6 +import client from "prom-client";
7 +import { newId } from "@websensor/core";
8 +import { closeDb } from "@websensor/db";
9 +import { config } from "./config";
10 +import { closeLive, registerLive } from "./live";
11 +import { registerRoutes } from "./routes";
12 +
13 +/**
14 + * WebSensor gateway: public entry point behind ngrok.
15 + * /api/v1/* REST + WebSocket (/api/v1/live)
16 + * /api/health /api/ready /api/metrics
17 + * everything else → Next.js frontend (loopback), with forwarded headers.
18 + * Also enforces the canonical host (websensor.io → www.websensor.io).
19 + */
20 +const registry = new client.Registry();
21 +client.collectDefaultMetrics({ register: registry, prefix: "websensor_api_" });
22 +const httpRequests = new client.Counter({ name: "websensor_api_requests_total", help: "API requests", labelNames: ["route", "status"], registers: [registry] });
23 +
24 +export async function buildServer() {
25 + const app = Fastify({
26 + logger: {
27 + level: config.logLevel,
28 + ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
29 + redact: ["req.headers.authorization", "req.headers.cookie", "req.headers['x-websensor-owner']"],
30 + },
31 + trustProxy: true,
32 + bodyLimit: 512 * 1024,
33 + genReqId: () => newId("req"),
34 + disableRequestLogging: config.env === "production",
35 + });
36 +
37 + // Canonical host redirect (apex → www) for every path.
38 + app.addHook("onRequest", async (req, reply) => {
39 + const host = (req.headers.host ?? "").split(":")[0];
40 + if (config.redirectApexToWww && host && host !== config.canonicalHost && host === config.canonicalHost.replace(/^www\./, "")) {
41 + return reply.redirect(`https://${config.canonicalHost}${req.url}`, 301);
42 + }
43 + });
44 +
45 + await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "authorization"], exposedHeaders: ["x-request-id"] });
46 + await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") });
47 + await app.register(websocket, { options: { maxPayload: 64 * 1024 } });
48 +
49 + app.addHook("onSend", async (req, reply) => {
50 + reply.header("x-request-id", req.id);
51 + if (req.url.startsWith("/api/")) {
52 + reply.header("cache-control", req.method === "GET" ? "public, max-age=5, stale-while-revalidate=30" : "no-store");
53 + httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) });
54 + }
55 + });
56 +
57 + app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; issues?: unknown }, req, reply) => {
58 + if (err.name === "ZodError" || err.issues) return reply.status(400).send({ error: "invalid_request", details: err.issues });
59 + if (err.statusCode && err.statusCode < 500) return reply.status(err.statusCode).send({ error: err.message });
60 + req.log.error({ err }, "unhandled error");
61 + return reply.status(500).send({ error: "internal_error", request_id: req.id });
62 + });
63 +
64 + app.get("/api/metrics", async (_req, reply) => {
65 + reply.header("content-type", registry.contentType);
66 + return registry.metrics();
67 + });
68 +
69 + await registerRoutes(app);
70 + await registerLive(app);
71 +
72 + // Frontend proxy — everything that is not /api/* goes to Next.js (loopback)
73 + // with forwarded headers so the app knows the public host/proto and the real client IP.
74 + await app.register(replyFrom, { base: config.webUrl, http: { requestOptions: { timeout: 60_000 } }, undici: { connections: 64, pipelining: 1 } });
75 + const forward = (req: FastifyRequest, reply: FastifyReply): FastifyReply => {
76 + if (req.url.startsWith("/api/")) return reply.status(404).send({ error: "not_found" });
77 + return reply.from(req.raw.url ?? "/", {
78 + rewriteRequestHeaders: (r, headers) => ({ ...headers, "x-forwarded-host": String(r.headers["x-forwarded-host"] ?? r.headers.host ?? ""), "x-forwarded-proto": String(r.headers["x-forwarded-proto"] ?? "https"), "x-real-ip": r.ip, "x-forwarded-for": r.ip }),
79 + });
80 + };
81 + const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] as const;
82 + app.route({ method: [...methods], url: "/", handler: forward });
83 + app.route({ method: [...methods], url: "/*", handler: forward });
84 +
85 + return app;
86 +}
87 +
88 +async function main(): Promise<void> {
89 + const app = await buildServer();
90 + await app.listen({ port: config.port, host: config.host });
91 + app.log.info({ port: config.port, web: config.webUrl, canonical: config.canonicalHost }, "websensor gateway listening");
92 + const shutdown = async (): Promise<void> => {
93 + await closeLive();
94 + await app.close();
95 + await closeDb();
96 + process.exit(0);
97 + };
98 + process.on("SIGINT", () => void shutdown());
99 + process.on("SIGTERM", () => void shutdown());
100 +}
101 +
102 +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) {
103 + main().catch((e) => {
104 + console.error(e);
105 + process.exit(1);
106 + });
107 +}
added apps/api/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added apps/engine/package.json +35 −0
@@ -0,0 +1,35 @@
1 +{
2 + "name": "@websensor/engine",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/index.ts",
8 + "start": "tsx src/index.ts",
9 + "registry:sync": "tsx src/cli.ts sync",
10 + "discover": "tsx src/cli.ts discover",
11 + "run-once": "tsx src/cli.ts run-once",
12 + "typecheck": "tsc -p tsconfig.json --noEmit",
13 + "test": "vitest run --passWithNoTests"
14 + },
15 + "dependencies": {
16 + "@anthropic-ai/sdk": "^0.124.0",
17 + "@websensor/connectors": "workspace:*",
18 + "@websensor/core": "workspace:*",
19 + "@websensor/db": "workspace:*",
20 + "@websensor/store": "workspace:*",
21 + "fastify": "^5.4.0",
22 + "ioredis": "^5.6.0",
23 + "pino": "^9.7.0",
24 + "pino-pretty": "^13.0.0",
25 + "prom-client": "^15.1.0",
26 + "tsx": "^4.20.0",
27 + "yaml": "^2.8.0",
28 + "zod": "^4.0.0"
29 + },
30 + "devDependencies": {
31 + "@types/node": "^24.0.0",
32 + "typescript": "^5.9.3",
33 + "vitest": "^3.2.0"
34 + }
35 +}
added apps/engine/src/cli.ts +81 −0
@@ -0,0 +1,81 @@
1 +import { closeDb, db, migrate, sensors, sources, sql } from "@websensor/db";
2 +import { closeDispatcher, discoverDomain } from "@websensor/connectors";
3 +import { loadRecent } from "./cluster";
4 +import { config, log } from "./config";
5 +import { closeRedis } from "./redis";
6 +import { runDiscovery, syncRegistry } from "./registry";
7 +import { runSensor } from "./pipeline";
8 +import { diffText, evaluateChange } from "@websensor/core";
9 +import { interpretChange, llmAvailable } from "./interpret";
10 +
11 +/**
12 + * Operator CLI:
13 + * tsx src/cli.ts sync — upsert sources/sensors/entities from config/sources.yaml
14 + * tsx src/cli.ts discover [sourceId…] — run discovery (+ promote validated endpoints)
15 + * tsx src/cli.ts probe <domain> — discovery dry-run for a domain (no DB writes)
16 + * tsx src/cli.ts run-once <sensorId> — run the full pipeline for one sensor now
17 + * tsx src/cli.ts run-due [n] — run up to n due sensors sequentially
18 + */
19 +async function main(): Promise<void> {
20 + const [cmd, ...args] = process.argv.slice(2);
21 + await migrate(config.databaseUrl);
22 + switch (cmd) {
23 + case "sync":
24 + await syncRegistry();
25 + break;
26 + case "discover":
27 + await syncRegistry();
28 + await runDiscovery({ sourceIds: args.length ? args : undefined });
29 + break;
30 + case "probe": {
31 + const r = await discoverDomain(args[0]!, { probePages: true });
32 + for (const e of r) console.log(`${e.type.padEnd(10)} ${e.value.toFixed(2)} ${String(e.itemCount ?? "").padStart(5)} ${e.url} (${e.evidence})`);
33 + break;
34 + }
35 + case "run-once": {
36 + await loadRecent();
37 + const s = (await db.select().from(sensors).where(sql`id = ${args[0]}`))[0];
38 + if (!s) throw new Error(`sensor ${args[0]} not found`);
39 + const src = (await db.select().from(sources).where(sql`id = ${s.sourceId}`))[0]!;
40 + const out = await runSensor(s, src);
41 + console.log(`${s.id}: ${out}`);
42 + break;
43 + }
44 + case "run-due": {
45 + await loadRecent();
46 + const n = Number(args[0] ?? 20);
47 + const rows = await db.select().from(sensors).where(sql`enabled and next_check_at <= now()`).orderBy(sql`next_check_at asc`).limit(n);
48 + const srcs = new Map((await db.select().from(sources)).map((s) => [s.id, s]));
49 + for (const s of rows) {
50 + const out = await runSensor(s, srcs.get(s.sourceId)!);
51 + console.log(`${s.id.padEnd(50)} ${out}`);
52 + }
53 + break;
54 + }
55 + case "llm-test": {
56 + if (!llmAvailable()) throw new Error("ANTHROPIC_API_KEY not set");
57 + const before = "API Pricing\nInput: $10 / million tokens\nOutput: $30 / million tokens\nBatch API: 50% discount";
58 + const after = "API Pricing\nInput: $8 / million tokens\nOutput: $24 / million tokens\nBatch API: 50% discount\nPrompt caching: 90% discount on cached input";
59 + const diff = diffText(before, after);
60 + const heuristic = evaluateChange(diff, { sensorType: "HTML", url: "https://example-ai.com/pricing", sourceCategories: ["ai"], title: "API Pricing" });
61 + const t0 = Date.now();
62 + const out = await interpretChange({ sourceName: "Example AI", sourceCategories: ["ai"], url: "https://example-ai.com/pricing", sensorName: "pricing", sensorType: "HTML", heuristic, diff, prelimImportance: Number(args[0] ?? 60), title: "API Pricing" });
63 + console.log(JSON.stringify(out, null, 2), `\n${Date.now() - t0} ms`);
64 + break;
65 + }
66 + default:
67 + console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n]");
68 + process.exitCode = 1;
69 + }
70 +}
71 +
72 +main()
73 + .catch((e) => {
74 + log.error({ err: (e as Error).stack ?? String(e) }, "cli failed");
75 + process.exitCode = 1;
76 + })
77 + .finally(async () => {
78 + await closeDispatcher();
79 + await closeRedis();
80 + await closeDb();
81 + });
added apps/engine/src/cluster.ts +135 −0
@@ -0,0 +1,135 @@
1 +import { jaccard, newId, shingles } from "@websensor/core";
2 +import { db, eventClusters, events, gte, sql, textArray } from "@websensor/db";
3 +
4 +/**
5 + * Novelty + clustering over a rolling in-memory window of recent events (loaded from
6 + * Postgres at startup). Similarity = Jaccard over word 3-shingles of title+summary.
7 + */
8 +interface RecentEvent {
9 + id: string;
10 + clusterId: string | null;
11 + sourceId: string;
12 + sensorId: string;
13 + eventType: string;
14 + entityIds: string[];
15 + detectedAt: number;
16 + sh: Set<string>;
17 + importance: number;
18 +}
19 +
20 +const WINDOW_MS = 72 * 3600e3;
21 +const CLUSTER_WINDOW_MS = 6 * 3600e3;
22 +let recent: RecentEvent[] = [];
23 +let loaded = false;
24 +
25 +export async function loadRecent(): Promise<void> {
26 + const since = new Date(Date.now() - WINDOW_MS);
27 + const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; entity_ids: string[] | null }>(sql`
28 + select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance,
29 + (select array_agg(entity_id) from event_entities ee where ee.event_id = e.id) as entity_ids
30 + from events e where e.detected_at >= ${since} order by e.detected_at desc limit 3000`);
31 + recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance }));
32 + loaded = true;
33 +}
34 +
35 +function prune(): void {
36 + const cutoff = Date.now() - WINDOW_MS;
37 + recent = recent.filter((r) => r.detectedAt >= cutoff);
38 +}
39 +
40 +export interface NoveltyResult {
41 + novelty: number;
42 + nearest: { id: string; similarity: number } | null;
43 + /** number of distinct sources reporting near-identical content */
44 + confirmations: number;
45 +}
46 +
47 +export async function assessNovelty(text: string, sourceId: string): Promise<NoveltyResult> {
48 + if (!loaded) await loadRecent();
49 + prune();
50 + const sh = shingles(text);
51 + let best = 0;
52 + let nearest: RecentEvent | null = null;
53 + const confirmingSources = new Set<string>();
54 + for (const r of recent) {
55 + const s = jaccard(sh, r.sh);
56 + if (s > best) {
57 + best = s;
58 + nearest = r;
59 + }
60 + if (s >= 0.45 && r.sourceId !== sourceId) confirmingSources.add(r.sourceId);
61 + }
62 + return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirmingSources.size };
63 +}
64 +
65 +export interface ClusterDecision {
66 + clusterId: string;
67 + created: boolean;
68 +}
69 +
70 +/**
71 + * Attach to an existing open cluster when the event shares an entity (or the same source)
72 + * with a recent event and is textually related, or when it is the same event type on the
73 + * same source within 30 minutes (e.g. one launch touching six pages). Otherwise open one.
74 + */
75 +export async function clusterEvent(ev: { id: string; sourceId: string; sensorId: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; categories: string[] }): Promise<ClusterDecision> {
76 + if (!loaded) await loadRecent();
77 + const sh = shingles(`${ev.title}\n${ev.summary}`);
78 + const now = ev.detectedAt.getTime();
79 + let bestCluster: string | null = null;
80 + let bestScore = 0;
81 + for (const r of recent) {
82 + if (!r.clusterId || now - r.detectedAt > CLUSTER_WINDOW_MS) continue;
83 + const sharedEntity = r.entityIds.some((e) => ev.entityIds.includes(e));
84 + const sameSource = r.sourceId === ev.sourceId;
85 + if (!sharedEntity && !sameSource) continue;
86 + const sim = jaccard(sh, r.sh);
87 + const closeInTime = now - r.detectedAt < 30 * 60e3;
88 + let score = 0;
89 + if (sim >= 0.22) score = sim + (sharedEntity ? 0.2 : 0);
90 + else if (sameSource && r.eventType === ev.eventType && closeInTime && r.sensorId !== ev.sensorId) score = 0.3;
91 + else if (sameSource && closeInTime && sim >= 0.12) score = 0.25;
92 + if (score > bestScore) {
93 + bestScore = score;
94 + bestCluster = r.clusterId;
95 + }
96 + }
97 + let clusterId: string;
98 + let created = false;
99 + if (bestCluster) {
100 + clusterId = bestCluster;
101 + await db.execute(sql`update event_clusters set event_count = event_count + 1, last_at = greatest(last_at, ${ev.detectedAt}), max_importance = greatest(max_importance, ${ev.importance}),
102 + entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))),
103 + categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))),
104 + title = case when ${ev.importance} > max_importance then ${ev.title} else title end,
105 + primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end
106 + where id = ${clusterId}`);
107 + } else {
108 + clusterId = newId("clu");
109 + created = true;
110 + await db.insert(eventClusters).values({ id: clusterId, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt });
111 + }
112 + recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance });
113 + if (recent.length > 5000) recent.length = 5000;
114 + return { clusterId, created };
115 +}
116 +
117 +/** Recent events of the same source published through announcement-type sensors (for silent-change detection). */
118 +export function recentAnnouncementSimilarity(sourceId: string, text: string, sinceMs: number): number {
119 + const sh = shingles(text);
120 + let best = 0;
121 + const cutoff = Date.now() - sinceMs;
122 + for (const r of recent) {
123 + if (r.sourceId !== sourceId || r.detectedAt < cutoff) continue;
124 + if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory/.test(r.eventType)) continue;
125 + best = Math.max(best, jaccard(sh, r.sh));
126 + }
127 + return best;
128 +}
129 +
130 +export async function recentClusterCount(sinceMs: number): Promise<number> {
131 + const rows = await db.select({ n: sql<number>`count(*)` }).from(eventClusters).where(gte(eventClusters.lastAt, new Date(Date.now() - sinceMs)));
132 + return Number(rows[0]?.n ?? 0);
133 +}
134 +
135 +export { events };
added apps/engine/src/config.ts +40 −0
@@ -0,0 +1,40 @@
1 +import pino from "pino";
2 +
3 +const env = process.env;
4 +
5 +export const config = {
6 + env: env.NODE_ENV ?? "development",
7 + logLevel: env.LOG_LEVEL ?? "info",
8 + databaseUrl: env.DATABASE_URL ?? "postgres://localhost:5432/websensor",
9 + redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
10 + sourcesFile: env.WS_SOURCES_FILE ?? "./config/sources.yaml",
11 + entitiesFile: env.WS_ENTITIES_FILE ?? "./config/entities.yaml",
12 + fetchConcurrency: Number(env.WS_FETCH_CONCURRENCY ?? 16),
13 + perHostConcurrency: Number(env.WS_PER_HOST_CONCURRENCY ?? 2),
14 + metricsPort: Number(env.ENGINE_METRICS_PORT ?? 8262),
15 + metricsHost: env.ENGINE_METRICS_HOST ?? "127.0.0.1",
16 + processingVersion: "event-pipeline-v1",
17 + /** heuristic signal threshold to promote a raw change to an event */
18 + meaningfulSignal: Number(env.WS_MEANINGFUL_SIGNAL ?? 0.32),
19 + llm: {
20 + apiKey: env.ANTHROPIC_API_KEY ?? "",
21 + modelFast: env.WS_LLM_MODEL_FAST ?? "claude-haiku-4-5",
22 + modelDeep: env.WS_LLM_MODEL_DEEP ?? "claude-opus-5",
23 + dailyCallBudget: Number(env.WS_LLM_DAILY_CALL_BUDGET ?? 600),
24 + minImportance: Number(env.WS_LLM_MIN_IMPORTANCE ?? 35),
25 + deepMinImportance: Number(env.WS_LLM_DEEP_MIN_IMPORTANCE ?? 78),
26 + },
27 + discovery: {
28 + enabled: (env.WS_DISCOVERY ?? "1") !== "0",
29 + /** re-run discovery for a source after this many days */
30 + intervalDays: Number(env.WS_DISCOVERY_INTERVAL_DAYS ?? 7),
31 + },
32 + deletion: { confirmations: Number(env.WS_DELETE_CONFIRMATIONS ?? 3), minSeparationMin: Number(env.WS_DELETE_SEPARATION_MIN ?? 60) },
33 + version: "0.1.0",
34 +};
35 +
36 +export const log = pino({
37 + level: config.logLevel,
38 + ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
39 + base: { service: "websensor-engine" },
40 +});
added apps/engine/src/entities.ts +62 −0
@@ -0,0 +1,62 @@
1 +import { db, entityAliases, entities, eq, sourceEntities, sql, textArray } from "@websensor/db";
2 +import { log } from "./config";
3 +
4 +/**
5 + * Entity resolution. The source's own entities are always attached (subject). Then aliases
6 + * of all known entities are matched against the event text (word-boundary, case-insensitive,
7 + * longest alias first) so products/models/regulators named in the change are linked too.
8 + */
9 +interface AliasIndex {
10 + loadedAt: number;
11 + byLen: { alias: string; re: RegExp; entityId: string }[];
12 + importance: Map<string, number>;
13 +}
14 +
15 +let index: AliasIndex | null = null;
16 +
17 +async function loadIndex(): Promise<AliasIndex> {
18 + if (index && Date.now() - index.loadedAt < 5 * 60_000) return index;
19 + const rows = await db.select({ alias: entityAliases.alias, entityId: entityAliases.entityId }).from(entityAliases);
20 + const ents = await db.select({ id: entities.id, importance: entities.importance }).from(entities);
21 + const byLen = rows
22 + .filter((r) => r.alias.length >= 3 && !/^\d+$/.test(r.alias))
23 + .map((r) => ({ alias: r.alias, entityId: r.entityId, re: new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias)}(?=$|[^\\p{L}\\p{N}])`, "iu") }))
24 + .sort((a, b) => b.alias.length - a.alias.length);
25 + index = { loadedAt: Date.now(), byLen, importance: new Map(ents.map((e) => [e.id, e.importance])) };
26 + log.debug({ aliases: byLen.length }, "alias index loaded");
27 + return index;
28 +}
29 +
30 +function escapeRe(s: string): string {
31 + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32 +}
33 +
34 +export function invalidateEntityIndex(): void {
35 + index = null;
36 +}
37 +
38 +export async function resolveEntities(input: { sourceId: string; text: string; hints: string[] }): Promise<{ subject: string[]; mentioned: string[]; importance: number }> {
39 + const idx = await loadIndex();
40 + const subject = (await db.select({ entityId: sourceEntities.entityId }).from(sourceEntities).where(eq(sourceEntities.sourceId, input.sourceId))).map((r) => r.entityId);
41 + // Only the organization (not every product) is a default subject; products are attached when named.
42 + const orgSubjects = subject.filter((s) => s.startsWith("org_"));
43 + const mentioned = new Set<string>();
44 + const hay = `${input.text}\n${input.hints.join("\n")}`;
45 + const hayLower = hay.toLowerCase();
46 + for (const a of idx.byLen) {
47 + if (mentioned.size >= 12) break;
48 + if (orgSubjects.includes(a.entityId)) continue;
49 + if (!hayLower.includes(a.alias)) continue;
50 + if (a.re.test(hay)) mentioned.add(a.entityId);
51 + }
52 + // Products of the source named in the text are subjects too.
53 + for (const s of subject) if (!s.startsWith("org_") && mentioned.has(s)) mentioned.delete(s), orgSubjects.push(s);
54 + const all = [...orgSubjects, ...mentioned];
55 + const importance = all.length ? Math.max(...all.map((e) => idx.importance.get(e) ?? 40)) : 40;
56 + return { subject: orgSubjects, mentioned: [...mentioned], importance };
57 +}
58 +
59 +export async function bumpEntityCounters(entityIds: string[], at: Date): Promise<void> {
60 + if (!entityIds.length) return;
61 + await db.execute(sql`update entities set event_count = event_count + 1, last_event_at = ${at} where id = any(${textArray(entityIds)})`);
62 +}
added apps/engine/src/index.ts +52 −0
@@ -0,0 +1,52 @@
1 +import { closeDb, migrate } from "@websensor/db";
2 +import { closeDispatcher } from "@websensor/connectors";
3 +import { loadRecent } from "./cluster";
4 +import { config, log } from "./config";
5 +import { startMetricsServer } from "./metrics";
6 +import { closeRedis } from "./redis";
7 +import { runDiscovery, syncRegistry } from "./registry";
8 +import { pruneOldRuns, rollupConnectorHealth, Scheduler } from "./scheduler";
9 +
10 +async function main(): Promise<void> {
11 + log.info({ env: config.env, version: config.version, llm: Boolean(config.llm.apiKey) }, "websensor engine starting");
12 + const applied = await migrate(config.databaseUrl);
13 + if (applied.length) log.info({ applied }, "migrations applied");
14 + await syncRegistry();
15 + await loadRecent();
16 + await startMetricsServer();
17 +
18 + const scheduler = new Scheduler();
19 + await scheduler.start();
20 +
21 + const timers: NodeJS.Timeout[] = [];
22 + timers.push(setInterval(() => rollupConnectorHealth().catch((e) => log.warn({ err: (e as Error).message }, "health rollup failed")), 60_000));
23 + timers.push(setInterval(() => pruneOldRuns().catch(() => undefined), 6 * 3600e3));
24 + await rollupConnectorHealth().catch(() => undefined);
25 +
26 + if (config.discovery.enabled) {
27 + // Background: validate feeds/sitemaps/status pages for sources not discovered recently, then weekly.
28 + setTimeout(() => runDiscovery({ onlyMissing: true }).catch((e) => log.warn({ err: (e as Error).message }, "discovery failed")), 5_000);
29 + timers.push(setInterval(() => runDiscovery({ onlyMissing: true }).catch(() => undefined), 24 * 3600e3));
30 + }
31 +
32 + let shuttingDown = false;
33 + const shutdown = async (signal: string): Promise<void> => {
34 + if (shuttingDown) return;
35 + shuttingDown = true;
36 + log.info({ signal }, "shutting down");
37 + for (const t of timers) clearInterval(t);
38 + await scheduler.stop();
39 + await closeDispatcher();
40 + await closeRedis();
41 + await closeDb();
42 + process.exit(0);
43 + };
44 + process.on("SIGINT", () => void shutdown("SIGINT"));
45 + process.on("SIGTERM", () => void shutdown("SIGTERM"));
46 + process.on("unhandledRejection", (e) => log.error({ err: e instanceof Error ? e.stack : String(e) }, "unhandled rejection"));
47 +}
48 +
49 +main().catch((e) => {
50 + log.fatal({ err: (e as Error).stack ?? String(e) }, "engine failed to start");
51 + process.exit(1);
52 +});
added apps/engine/src/interpret.ts +153 −0
@@ -0,0 +1,153 @@
1 +import Anthropic from "@anthropic-ai/sdk";
2 +import { EVENT_TYPES, type DiffResult, type HeuristicResult } from "@websensor/core";
3 +import { db, llmUsage, sql } from "@websensor/db";
4 +import { config, log } from "./config";
5 +import { bumpDaily, m } from "./metrics";
6 +
7 +/**
8 + * Stage-2 interpretation with Claude. Only called for candidates that already cleared the
9 + * heuristic bar and a preliminary importance threshold; a daily call budget caps spend.
10 + * Routine changes go to the fast model; high-importance candidates get the deep model.
11 + * Output is strictly structured (JSON schema) — never free text.
12 + */
13 +export interface Interpretation {
14 + event_type: string;
15 + title: string;
16 + summary: string;
17 + why_it_matters: string;
18 + who_it_affects: string;
19 + observed: string;
20 + inferred: string;
21 + meaningful: boolean;
22 + severity: number;
23 + confidence: number;
24 + entities: string[];
25 + keywords: string[];
26 + announced: boolean | null;
27 + model: string;
28 + input_tokens: number;
29 + output_tokens: number;
30 +}
31 +
32 +const client = config.llm.apiKey ? new Anthropic({ apiKey: config.llm.apiKey, timeout: 60_000, maxRetries: 1 }) : null;
33 +
34 +const SYSTEM = `You are the interpretation stage of WebSensor, a platform that monitors official public web sources and turns detected changes into precise, sober intelligence events.
35 +You receive a detected change on one monitored endpoint: the source, the URL, the sensor kind, the heuristic pre-classification, extracted facts and the diff (before → after, or new/removed items).
36 +Your job: decide whether the change is meaningful to a professional audience, classify it, and write a factual title and summary.
37 +Rules:
38 +- Never sensationalize. No marketing tone. State what is observed; keep inference clearly separate.
39 +- "observed" must only contain facts visible in the diff. "inferred" may contain a cautious interpretation or be empty.
40 +- Titles: ≤ 110 characters, start with the organization name, no trailing period, no emoji.
41 +- Summary: 1–3 sentences, concrete (numbers, names, versions when present).
42 +- meaningful=false for cosmetic edits, typos, navigation/footer churn, copyright years, tracking or timestamp noise, and generic marketing rewording with no new information.
43 +- severity: 0–100 intrinsic importance of this kind of change for people who follow this organization (pricing/security/outage/model launch high; doc typo low).
44 +- confidence: 0–100 how sure you are of the classification given the evidence.
45 +- entities: organizations, products, models, APIs, drugs, standards explicitly named in the change (canonical names).
46 +- announced: true if the diff itself is an announcement (news/blog/release item), false if it is a silent modification of an existing page, null if unclear.
47 +Allowed event_type values: ${Object.keys(EVENT_TYPES).join(", ")}.`;
48 +
49 +const schema = {
50 + type: "object",
51 + additionalProperties: false,
52 + properties: {
53 + event_type: { type: "string", enum: Object.keys(EVENT_TYPES) },
54 + title: { type: "string" },
55 + summary: { type: "string" },
56 + why_it_matters: { type: "string" },
57 + who_it_affects: { type: "string" },
58 + observed: { type: "string" },
59 + inferred: { type: "string" },
60 + meaningful: { type: "boolean" },
61 + severity: { type: "integer", description: "0–100" },
62 + confidence: { type: "integer", description: "0–100" },
63 + entities: { type: "array", items: { type: "string" } },
64 + keywords: { type: "array", items: { type: "string" } },
65 + announced: { type: ["boolean", "null"] },
66 + },
67 + required: ["event_type", "title", "summary", "why_it_matters", "who_it_affects", "observed", "inferred", "meaningful", "severity", "confidence", "entities", "keywords", "announced"],
68 +} as const;
69 +
70 +let budgetDay = "";
71 +let budgetUsed = 0;
72 +
73 +async function loadBudget(): Promise<void> {
74 + const today = new Date().toISOString().slice(0, 10);
75 + if (budgetDay === today) return;
76 + budgetDay = today;
77 + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from llm_usage where at >= current_date`);
78 + budgetUsed = Number(r.rows[0]?.n ?? 0);
79 +}
80 +
81 +export function llmAvailable(): boolean {
82 + return client !== null;
83 +}
84 +
85 +export async function interpretChange(input: { sourceName: string; sourceCategories: string[]; url: string; sensorName: string; sensorType: string; heuristic: HeuristicResult; diff: DiffResult; prelimImportance: number; title?: string | null }): Promise<Interpretation | null> {
86 + if (!client) return null;
87 + await loadBudget();
88 + if (budgetUsed >= config.llm.dailyCallBudget) {
89 + log.warn({ used: budgetUsed }, "LLM daily budget exhausted — heuristics only");
90 + return null;
91 + }
92 + const deep = input.prelimImportance >= config.llm.deepMinImportance;
93 + const model = deep ? config.llm.modelDeep : config.llm.modelFast;
94 + const diffText = renderDiff(input.diff).slice(0, 14_000);
95 + const user = `SOURCE: ${input.sourceName} (${input.sourceCategories.join(", ") || "n/a"})
96 +URL: ${input.url}
97 +SENSOR: ${input.sensorName} [${input.sensorType}]${input.title ? `\nPAGE TITLE: ${input.title}` : ""}
98 +HEURISTIC: type=${input.heuristic.eventType} signal=${input.heuristic.signal.toFixed(2)} magnitude=${input.heuristic.magnitude} noise=${input.heuristic.noiseRatio.toFixed(2)} reasons=${input.heuristic.reasons.join("; ") || "none"}
99 +FACTS: ${input.heuristic.facts.map((f) => `${f.kind}: ${f.before ?? "∅"} → ${f.after ?? "∅"}`).join(" | ") || "none"}
100 +
101 +DIFF:
102 +${diffText}`;
103 +
104 + budgetUsed++;
105 + const started = Date.now();
106 + try {
107 + // Haiku 4.5 does not accept `effort`; the deep model (Opus 5) runs adaptive thinking at medium effort.
108 + const params: Anthropic.MessageCreateParamsNonStreaming = {
109 + model,
110 + max_tokens: 1500,
111 + system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
112 + messages: [{ role: "user", content: user }],
113 + output_config: { format: { type: "json_schema", schema: schema as unknown as Record<string, unknown> }, ...(deep ? { effort: "medium" as const } : {}) },
114 + };
115 + const res = await client.messages.create(params);
116 + const text = res.content.find((b) => b.type === "text")?.text ?? "";
117 + if (res.stop_reason === "refusal" || !text) throw new Error(`no text (stop_reason=${res.stop_reason})`);
118 + const parsed = JSON.parse(text) as Omit<Interpretation, "model" | "input_tokens" | "output_tokens">;
119 + const usage = { input: res.usage.input_tokens + (res.usage.cache_read_input_tokens ?? 0) + (res.usage.cache_creation_input_tokens ?? 0), output: res.usage.output_tokens };
120 + m.llmCalls.inc({ model, ok: "true" });
121 + m.llmTokens.inc({ model, direction: "input" }, usage.input);
122 + m.llmTokens.inc({ model, direction: "output" }, usage.output);
123 + await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: usage.input, outputTokens: usage.output, ok: true });
124 + await bumpDaily({ llm_calls: 1, llm_input_tokens: usage.input, llm_output_tokens: usage.output });
125 + log.debug({ model, ms: Date.now() - started, type: parsed.event_type, meaningful: parsed.meaningful }, "llm interpretation");
126 + if (!(parsed.event_type in EVENT_TYPES)) parsed.event_type = input.heuristic.eventType;
127 + parsed.severity = Math.max(0, Math.min(100, Math.round(Number(parsed.severity) || 0)));
128 + parsed.confidence = Math.max(0, Math.min(100, Math.round(Number(parsed.confidence) || 0)));
129 + return { ...parsed, model, input_tokens: usage.input, output_tokens: usage.output };
130 + } catch (e) {
131 + m.llmCalls.inc({ model, ok: "false" });
132 + await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: 0, outputTokens: 0, ok: false }).catch(() => undefined);
133 + const err = e as Error & { status?: number };
134 + log.warn({ model, status: err.status, err: err.message }, "llm interpretation failed — falling back to heuristics");
135 + return null;
136 + }
137 +}
138 +
139 +export function renderDiff(d: DiffResult): string {
140 + if (d.kind === "text") {
141 + const lines: string[] = [];
142 + for (const mo of d.modified.slice(0, 60)) lines.push(`- ${mo.before}\n+ ${mo.after}`);
143 + for (const r of d.removed.slice(0, 60)) lines.push(`- ${r}`);
144 + for (const a of d.added.slice(0, 80)) lines.push(`+ ${a}`);
145 + return lines.join("\n");
146 + }
147 + if (d.kind === "json") return d.unified;
148 + const lines: string[] = [];
149 + for (const a of d.added.slice(0, 30)) lines.push(`+ NEW: ${String(a.title ?? a.url ?? a.key)}${a.summary ? `\n ${String(a.summary).slice(0, 500)}` : ""}${a.url ? `\n ${String(a.url)}` : ""}${a.publishedAt ? `\n published ${String(a.publishedAt)}` : ""}`);
150 + for (const r of d.removed.slice(0, 30)) lines.push(`- REMOVED: ${String(r.title ?? r.url ?? r.key)}`);
151 + for (const mo of d.modified.slice(0, 30)) lines.push(`~ UPDATED: ${String(mo.after.title ?? mo.key)} (${mo.fields.join(", ")})\n before: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.before[f])}`).join(" ")}\n after: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.after[f])}`).join(" ")}`);
152 + return lines.join("\n");
153 +}
added apps/engine/src/metrics.ts +46 −0
@@ -0,0 +1,46 @@
1 +import Fastify from "fastify";
2 +import client from "prom-client";
3 +import { db, sql } from "@websensor/db";
4 +import { config, log } from "./config";
5 +
6 +export const registry = new client.Registry();
7 +client.collectDefaultMetrics({ register: registry, prefix: "websensor_engine_" });
8 +
9 +export const m = {
10 + checks: new client.Counter({ name: "websensor_checks_total", help: "Sensor checks", labelNames: ["connector", "outcome"], registers: [registry] }),
11 + bytes: new client.Counter({ name: "websensor_fetched_bytes_total", help: "Bytes fetched", labelNames: ["connector"], registers: [registry] }),
12 + changes: new client.Counter({ name: "websensor_changes_total", help: "Raw changes", labelNames: ["connector", "meaningful"], registers: [registry] }),
13 + events: new client.Counter({ name: "websensor_events_total", help: "Events published", labelNames: ["event_type", "silent"], registers: [registry] }),
14 + llmCalls: new client.Counter({ name: "websensor_llm_calls_total", help: "LLM calls", labelNames: ["model", "ok"], registers: [registry] }),
15 + llmTokens: new client.Counter({ name: "websensor_llm_tokens_total", help: "LLM tokens", labelNames: ["model", "direction"], registers: [registry] }),
16 + fetchDuration: new client.Histogram({ name: "websensor_fetch_duration_seconds", help: "Fetch latency", labelNames: ["connector"], buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10, 25], registers: [registry] }),
17 + processingLatency: new client.Histogram({ name: "websensor_processing_latency_seconds", help: "Detected → published", buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 15], registers: [registry] }),
18 + queueDue: new client.Gauge({ name: "websensor_sensors_due", help: "Sensors due for a check", registers: [registry] }),
19 + inflight: new client.Gauge({ name: "websensor_inflight_checks", help: "Checks in flight", registers: [registry] }),
20 + httpStatus: new client.Counter({ name: "websensor_http_status_total", help: "HTTP status codes", labelNames: ["status"], registers: [registry] }),
21 +};
22 +
23 +/** Daily counters in Postgres (feed the public homepage stats). */
24 +export async function bumpDaily(fields: Partial<Record<"checks" | "not_modified" | "bytes" | "raw_changes" | "events" | "silent_events" | "errors" | "llm_calls" | "llm_input_tokens" | "llm_output_tokens" | "scrapfly_calls", number>>): Promise<void> {
25 + const keys = Object.keys(fields) as (keyof typeof fields)[];
26 + if (!keys.length) return;
27 + const sets = keys.map((k) => sql.raw(`${k} = metrics_daily.${k} + ${Number(fields[k] ?? 0)}`));
28 + const cols = keys.map((k) => sql.raw(k));
29 + const vals = keys.map((k) => sql`${Number(fields[k] ?? 0)}`);
30 + try {
31 + await db.execute(sql`insert into metrics_daily (day, ${sql.join(cols, sql`, `)}) values (current_date, ${sql.join(vals, sql`, `)}) on conflict (day) do update set ${sql.join(sets, sql`, `)}`);
32 + } catch (e) {
33 + log.warn({ err: (e as Error).message }, "metrics_daily update failed");
34 + }
35 +}
36 +
37 +export async function startMetricsServer(): Promise<void> {
38 + const app = Fastify({ logger: false });
39 + app.get("/metrics", async (_req, reply) => {
40 + reply.header("content-type", registry.contentType);
41 + return registry.metrics();
42 + });
43 + app.get("/health", async () => ({ status: "ok", service: "engine", version: config.version, time: new Date().toISOString() }));
44 + await app.listen({ port: config.metricsPort, host: config.metricsHost });
45 + log.info({ port: config.metricsPort }, "engine metrics listening");
46 +}
added apps/engine/src/pipeline.ts +404 −0
@@ -0,0 +1,404 @@
1 +import { computeConfidence, computeImportance, describeChange, diffIsEmpty, diffJson, diffList, diffText, evaluateChange, eventTypeSpec, newId, nextIntervalSeconds, slugify, sourceImportanceFromTier, summarizeDiff, type DiffResult, type HeuristicResult, type NormalizedContent, type Observation, type SensorEndpoint, type Tier, FEED_CHANNELS } from "@websensor/core";
2 +import { getConnector, NormalizeError, PARSER_VERSION, scrapflyAvailable, scrapflyFetch } from "@websensor/connectors";
3 +import { changes, db, eventEntities, events, interpretations, sensorRuns, sensors, snapshots, sql, textArray, type Sensor, type Source } from "@websensor/db";
4 +import { getBlobStore } from "@websensor/store";
5 +import { assessNovelty, clusterEvent, recentAnnouncementSimilarity } from "./cluster";
6 +import { config, log } from "./config";
7 +import { bumpEntityCounters, resolveEntities } from "./entities";
8 +import { interpretChange, llmAvailable, renderDiff, type Interpretation } from "./interpret";
9 +import { bumpDaily, m } from "./metrics";
10 +import { publishChange, publishEvent } from "./redis";
11 +
12 +export type RunOutcome = "baseline" | "unchanged" | "not_modified" | "changed" | "event" | "error" | "missing" | "rate_limited" | "parse_error";
13 +
14 +interface CanonicalBlob {
15 + mode: NormalizedContent["mode"];
16 + text?: string;
17 + json?: unknown;
18 + items?: { key: string; [k: string]: unknown }[];
19 + compareFields?: string[];
20 + title?: string | null;
21 + headings?: string[];
22 + extractionConfidence: number;
23 +}
24 +
25 +const ANNOUNCEMENT_SENSORS = new Set(["RSS", "ATOM", "STATUSPAGE", "GITHUB_RELEASE", "REST_API"]);
26 +
27 +export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutcome> {
28 + const started = new Date();
29 + const runId = newId("run");
30 + const connector = getConnector(sensor.connector);
31 + const endpoint: SensorEndpoint = { id: sensor.id, sourceId: sensor.sourceId, name: sensor.name, url: sensor.url, type: sensor.type as SensorEndpoint["type"], tier: sensor.tier as Tier, connector: sensor.connector, config: sensor.config, etag: sensor.etag, lastModified: sensor.lastModified, state: sensor.state ?? null };
32 + const stop = m.fetchDuration.startTimer({ connector: sensor.connector });
33 + let obs: Observation;
34 + try {
35 + obs = await connector.fetch(endpoint);
36 + } catch (e) {
37 + obs = { sensorId: sensor.id, url: sensor.url, fetchedAt: new Date(), notModified: false, error: { code: "connector_threw", message: (e as Error).message }, meta: { status: 0, url: sensor.url, finalUrl: sensor.url, contentType: null, contentLength: 0, etag: null, lastModified: null, durationMs: Date.now() - started.getTime(), redirects: 0, method: "GET", headers: {} } };
38 + }
39 + stop();
40 + // Anti-bot response on a source that permits the Scrapfly fallback (acquisition step 14).
41 + if (!obs.error && [403, 429, 503].includes(obs.meta.status) && (source.fallback as { scrapfly?: boolean }).scrapfly && scrapflyAvailable() && ["http", "rss", "sitemap"].includes(sensor.connector)) {
42 + const via = await scrapflyFetch(sensor.id, sensor.url, { renderJs: Boolean((sensor.config as { renderJs?: boolean }).renderJs) });
43 + log.info({ sensor: sensor.id, direct: obs.meta.status, via: via.error ? via.error.code : via.meta.status }, "scrapfly fallback");
44 + if (!via.error && via.meta.status < 400 && via.body) {
45 + obs = via;
46 + await bumpDaily({ scrapfly_calls: 1 });
47 + }
48 + }
49 + m.bytes.inc({ connector: sensor.connector }, obs.meta.contentLength);
50 + if (obs.meta.status) m.httpStatus.inc({ status: String(obs.meta.status) });
51 +
52 + const finish = async (outcome: RunOutcome, extra: { error?: string; snapshotId?: string; changed?: boolean; eventAt?: Date; rateLimitedUntil?: Date | null } = {}): Promise<RunOutcome> => {
53 + const isErr = outcome === "error" || outcome === "parse_error" || outcome === "rate_limited";
54 + const consecutive = isErr ? sensor.consecutiveErrors + 1 : 0;
55 + const health = outcome === "rate_limited" ? "RATE_LIMITED" : isErr ? (consecutive >= 5 ? "ERROR" : "DEGRADED") : outcome === "missing" ? "DEGRADED" : "UP";
56 + const changes7d = await countChanges7d(sensor.id);
57 + const events7d = await countEvents7d(sensor.id);
58 + let interval = nextIntervalSeconds({ tier: sensor.tier as Tier, baseIntervalSeconds: sensor.baseIntervalSeconds, lastChangeAt: extra.changed ? new Date() : sensor.lastChangeAt, changes7d, events7d, consecutiveErrors: consecutive, lastWas304: outcome === "not_modified" });
59 + if (extra.rateLimitedUntil) interval = Math.max(interval, Math.ceil((extra.rateLimitedUntil.getTime() - Date.now()) / 1000));
60 + const avg = sensor.avgLatencyMs ? Math.round(sensor.avgLatencyMs * 0.8 + obs.meta.durationMs * 0.2) : obs.meta.durationMs;
61 + await db.insert(sensorRuns).values({ id: runId, sensorId: sensor.id, startedAt: started, finishedAt: new Date(), httpStatus: obs.meta.status || null, outcome, error: extra.error?.slice(0, 500) ?? obs.error?.message.slice(0, 500) ?? null, durationMs: obs.meta.durationMs, bytes: obs.meta.contentLength, fetchMethod: obs.meta.method, snapshotId: extra.snapshotId ?? null });
62 + await db
63 + .update(sensors)
64 + .set({
65 + lastCheckAt: new Date(),
66 + lastStatus: obs.meta.status || null,
67 + lastError: isErr || outcome === "missing" ? (extra.error ?? obs.error?.message ?? `HTTP ${obs.meta.status}`).slice(0, 500) : null,
68 + consecutiveErrors: consecutive,
69 + health,
70 + totalRuns: sensor.totalRuns + 1,
71 + totalNotModified: sensor.totalNotModified + (outcome === "not_modified" ? 1 : 0),
72 + avgLatencyMs: avg,
73 + nextCheckAt: new Date(Date.now() + interval * 1000),
74 + updatedAt: new Date(),
75 + ...(extra.changed ? { lastChangeAt: new Date() } : {}),
76 + ...(extra.eventAt ? { lastEventAt: extra.eventAt } : {}),
77 + ...(obs.meta.status >= 200 && obs.meta.status < 300 ? { etag: obs.meta.etag ?? sensor.etag, lastModified: obs.meta.lastModified ?? sensor.lastModified } : {}),
78 + })
79 + .where(sql`id = ${sensor.id}`);
80 + m.checks.inc({ connector: sensor.connector, outcome });
81 + await bumpDaily({ checks: 1, bytes: obs.meta.contentLength, not_modified: outcome === "not_modified" ? 1 : 0, errors: isErr ? 1 : 0 });
82 + return outcome;
83 + };
84 +
85 + // ---- Transport-level outcomes -------------------------------------------------------
86 + if (obs.error) return finish("error", { error: `${obs.error.code}: ${obs.error.message}` });
87 + if (obs.notModified) return finish("not_modified");
88 + const st = obs.meta.status;
89 + if (st === 429) {
90 + const ra = Number(obs.meta.headers["retry-after"] ?? 0);
91 + return finish("rate_limited", { error: "HTTP 429", rateLimitedUntil: new Date(Date.now() + Math.min(6 * 3600e3, (ra > 0 ? ra : 900) * 1000)) });
92 + }
93 + if (st === 404 || st === 410) return handleMissing(sensor, source, obs, finish);
94 + if (st >= 400) return finish("error", { error: `HTTP ${st}` });
95 + if (!obs.body && obs.meta.method !== "HEAD") return finish("error", { error: "empty body" });
96 +
97 + // ---- Normalize --------------------------------------------------------------------
98 + let norm: NormalizedContent;
99 + try {
100 + norm = await connector.normalize(endpoint, obs);
101 + } catch (e) {
102 + const msg = e instanceof NormalizeError ? `${e.code}: ${e.message}` : (e as Error).message;
103 + return finish("parse_error", { error: msg });
104 + }
105 + if (norm.state) await db.update(sensors).set({ state: norm.state }).where(sql`id = ${sensor.id}`);
106 +
107 + // Restore: page was flagged missing but is back.
108 + const missingState = (sensor.state as { missing?: { count: number; removedEventAt?: string } } | null)?.missing;
109 + if (missingState) await db.update(sensors).set({ state: { ...(norm.state ?? sensor.state ?? {}), missing: null } }).where(sql`id = ${sensor.id}`);
110 +
111 + // ---- Compare with previous snapshot -------------------------------------------------
112 + const prev = sensor.lastSnapshotId ? (await db.select().from(snapshots).where(sql`id = ${sensor.lastSnapshotId}`))[0] : undefined;
113 + if (prev && prev.canonicalHash === norm.canonicalHash) {
114 + await touchUrl(sensor, source, false);
115 + return finish("unchanged");
116 + }
117 +
118 + // Store snapshot (raw + canonical) — evidence is immutable.
119 + const store = getBlobStore();
120 + const raw = obs.body ? await store.put(obs.body) : null;
121 + const canonicalBlob: CanonicalBlob = { mode: norm.mode, text: norm.text, json: norm.json, items: norm.items, compareFields: norm.compareFields, title: norm.title ?? null, headings: norm.headings, extractionConfidence: norm.extractionConfidence };
122 + const canonical = await store.put(JSON.stringify(canonicalBlob));
123 + const snapId = newId("snap");
124 + await db.insert(snapshots).values({
125 + id: snapId,
126 + sensorId: sensor.id,
127 + url: obs.meta.finalUrl || sensor.url,
128 + capturedAt: obs.fetchedAt,
129 + httpStatus: st,
130 + contentType: obs.meta.contentType,
131 + contentLength: obs.meta.contentLength,
132 + contentHash: norm.rawHash,
133 + canonicalHash: norm.canonicalHash,
134 + semanticHash: norm.semanticHash,
135 + etag: obs.meta.etag,
136 + lastModified: obs.meta.lastModified,
137 + storageKey: raw?.key ?? null,
138 + canonicalStorageKey: canonical.key,
139 + parserVersion: PARSER_VERSION,
140 + fetchDurationMs: obs.meta.durationMs,
141 + fetchMethod: obs.meta.method,
142 + mode: norm.mode,
143 + title: norm.title ?? null,
144 + publishedAt: norm.publishedAt ?? null,
145 + extractionConfidence: norm.extractionConfidence,
146 + extra: norm.extra ?? null,
147 + });
148 + await db.update(sensors).set({ lastSnapshotId: snapId }).where(sql`id = ${sensor.id}`);
149 + await touchUrl(sensor, source, true, snapId);
150 +
151 + if (!prev) return finish("baseline", { snapshotId: snapId });
152 +
153 + // ---- Diff -----------------------------------------------------------------------------
154 + let prevBlob: CanonicalBlob | null = null;
155 + try {
156 + prevBlob = prev.canonicalStorageKey ? (JSON.parse(await store.getText(prev.canonicalStorageKey)) as CanonicalBlob) : null;
157 + } catch (e) {
158 + log.warn({ sensor: sensor.id, err: (e as Error).message }, "previous canonical blob unreadable");
159 + }
160 + if (!prevBlob || prevBlob.mode !== norm.mode) return finish("changed", { snapshotId: snapId, changed: true });
161 +
162 + let diff: DiffResult;
163 + if (norm.mode === "list") {
164 + const seen = new Set<string>(Array.isArray((sensor.state as { seenKeys?: string[] } | null)?.seenKeys) ? ((sensor.state as { seenKeys: string[] }).seenKeys ?? []) : []);
165 + const prevItems = prevBlob.items ?? [];
166 + const curItems = norm.items ?? [];
167 + diff = diffList(prevItems, curItems, norm.compareFields ?? []);
168 + // Feeds: items scrolling out of the window are not removals. Anything seen before is not "new".
169 + if (sensor.type === "RSS" || sensor.type === "ATOM" || sensor.type === "GITHUB_RELEASE" || sensor.type === "REST_API" || sensor.type === "JSON") {
170 + const staleBefore = Date.now() - 14 * 86400e3;
171 + diff = {
172 + ...diff,
173 + removed: [],
174 + // never seen before AND not an old item resurfacing at the feed window boundary (backfills, reordering)
175 + added: diff.added.filter((i) => !seen.has(i.key) && !(typeof i.publishedAt === "string" && i.publishedAt && new Date(i.publishedAt).getTime() < staleBefore)),
176 + };
177 + }
178 + // Statuspage summaries only list active incidents/maintenances: an item leaving the list is a resolution, not a removal.
179 + if (sensor.type === "STATUSPAGE") diff = { ...diff, removed: [] };
180 + // Sitemaps/statuspages: a sudden > 50 % shrink is more likely a partial response than mass deletion.
181 + if (prevItems.length >= 20 && curItems.length < prevItems.length * 0.5) diff = { ...diff, removed: [] };
182 + } else if (norm.mode === "json") {
183 + diff = diffJson(prevBlob.json, norm.json);
184 + } else {
185 + diff = diffText(prevBlob.text ?? "", norm.text ?? "", `${sensor.id}@${prev.capturedAt.toISOString()}`, `${sensor.id}@${obs.fetchedAt.toISOString()}`);
186 + }
187 + if (diffIsEmpty(diff)) return finish("unchanged", { snapshotId: snapId });
188 +
189 + const heuristic = evaluateChange(diff, { sensorType: sensor.type, url: sensor.url, sourceCategories: source.categories, title: norm.title });
190 + const thinFlip = (prevBlob.extractionConfidence ?? 1) < 0.5 || norm.extractionConfidence < 0.5;
191 + const meaningfulByRules = heuristic.signal >= config.meaningfulSignal && !thinFlip;
192 + const changeId = newId("chg");
193 + const diffBlob = await store.put(diff.kind === "text" ? diff.unified : renderDiff(diff));
194 + await db.insert(changes).values({ id: changeId, sensorId: sensor.id, oldSnapshotId: prev.id, newSnapshotId: snapId, detectedAt: obs.fetchedAt, kind: diff.kind, diff: summarizeDiff(diff), diffStorageKey: diffBlob.key, signal: heuristic.signal, noiseRatio: heuristic.noiseRatio, magnitude: heuristic.magnitude, heuristic: heuristic as unknown as Record<string, unknown>, meaningful: false });
195 + await db.update(sensors).set({ rawChanges: sensor.rawChanges + 1 }).where(sql`id = ${sensor.id}`);
196 + await db.execute(sql`insert into url_history (url, at, kind, snapshot_id, change_id) values (${sensor.url}, ${obs.fetchedAt}, 'change', ${snapId}, ${changeId})`);
197 + await db.execute(sql`update urls set change_count = change_count + 1 where url = ${sensor.url}`);
198 + await bumpDaily({ raw_changes: 1 });
199 + m.changes.inc({ connector: sensor.connector, meaningful: String(meaningfulByRules) });
200 + await publishChange({ id: changeId, sensorId: sensor.id, sourceId: source.id, kind: diff.kind, signal: heuristic.signal, at: obs.fetchedAt.toISOString() });
201 + log.info({ sensor: sensor.id, kind: diff.kind, signal: heuristic.signal.toFixed(2), type: heuristic.eventType, thin: thinFlip }, "change detected");
202 +
203 + if (!meaningfulByRules) return finish("changed", { snapshotId: snapId, changed: true });
204 +
205 + // ---- Event --------------------------------------------------------------------------
206 + const ev = await createEvent({ sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic });
207 + if (!ev) return finish("changed", { snapshotId: snapId, changed: true });
208 + return finish("event", { snapshotId: snapId, changed: true, eventAt: ev.detectedAt });
209 +}
210 +
211 +async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observation; norm: NormalizedContent; prev: { id: string; capturedAt: Date }; snapId: string; changeId: string; diff: DiffResult; heuristic: HeuristicResult }): Promise<{ id: string; detectedAt: Date } | null> {
212 + const { sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic } = ctx;
213 + const detectedAt = obs.fetchedAt;
214 + const base = describeChange(heuristic, diff, { sourceName: source.name, url: sensor.url, sensorName: sensor.name });
215 + const textForMatching = `${base.title}\n${base.summary}\n${renderDiff(diff).slice(0, 4000)}`;
216 +
217 + const ent = await resolveEntities({ sourceId: source.id, text: textForMatching, hints: heuristic.keywords });
218 + const nov = await assessNovelty(`${base.title}\n${base.summary}`, source.id);
219 + const sourceImportance = sourceImportanceFromTier(sensor.tier, source.importanceWeight * sensor.importanceWeight);
220 + let eventType = heuristic.eventType;
221 + let prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations });
222 +
223 + // Duplicate suppression: near-identical to something we already published (syndication / re-fetch).
224 + if (nov.novelty < 12 && nov.nearest) {
225 + log.info({ sensor: sensor.id, nearest: nov.nearest.id, sim: nov.nearest.similarity }, "suppressed near-duplicate event");
226 + return null;
227 + }
228 +
229 + let llm: Interpretation | null = null;
230 + if (llmAvailable() && prelim.score >= config.llm.minImportance) {
231 + llm = await interpretChange({ sourceName: source.name, sourceCategories: source.categories, url: sensor.url, sensorName: sensor.name, sensorType: sensor.type, heuristic, diff, prelimImportance: prelim.score, title: norm.title });
232 + if (llm && !llm.meaningful) {
233 + log.info({ sensor: sensor.id, type: llm.event_type }, "LLM judged change not meaningful");
234 + await db.update(changes).set({ heuristic: { ...(heuristic as unknown as Record<string, unknown>), llm: { meaningful: false, model: llm.model, title: llm.title } } }).where(sql`id = ${changeId}`);
235 + return null;
236 + }
237 + if (llm) {
238 + eventType = llm.event_type;
239 + const spec = eventTypeSpec(eventType);
240 + prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations });
241 + prelim.components.severity = Math.round((spec.severity + llm.severity) / 2);
242 + prelim.score = Math.round(10 * (0.25 * prelim.components.severity + 0.2 * prelim.components.source + 0.15 * prelim.components.entity + 0.15 * prelim.components.novelty + 0.1 * prelim.components.magnitude + 0.05 * prelim.components.confirmation + 0.05 * prelim.components.userImpact + 0.05 * prelim.components.unusualness)) / 10;
243 + }
244 + }
245 +
246 + const title = (llm?.title ?? base.title).slice(0, 180);
247 + const summary = (llm?.summary ?? base.summary).slice(0, 1500);
248 + const spec = eventTypeSpec(eventType);
249 + const isAnnouncementSensor = ANNOUNCEMENT_SENSORS.has(sensor.type);
250 + const announcedSim = isAnnouncementSensor ? 1 : recentAnnouncementSimilarity(source.id, `${title}\n${summary}`, 12 * 3600e3);
251 + const silentChange = !isAnnouncementSensor && (llm ? llm.announced === false : !spec.usuallyAnnounced) && announcedSim < 0.3 && prelim.score >= 35;
252 +
253 + // Entities named by the LLM that resolve to known aliases
254 + const extra = llm ? await resolveEntities({ sourceId: source.id, text: llm.entities.join("\n"), hints: [] }) : null;
255 + const entityIds = [...new Set([...ent.subject, ...ent.mentioned, ...(extra?.mentioned ?? [])])];
256 +
257 + const confidence = computeConfidence({
258 + sourceAuthenticity: 1,
259 + extraction: norm.extractionConfidence,
260 + diffClarity: 1 - heuristic.noiseRatio,
261 + structured: diff.kind !== "text",
262 + confirmations: nov.confirmations,
263 + llmAgreement: llm ? (llm.event_type === heuristic.eventType ? 1 : 0.6) * (llm.confidence / 100) : heuristic.signal,
264 + });
265 + const evidenceLabel = nov.confirmations > 0 ? "CONFIRMED" : llm && llm.inferred && llm.confidence < 60 ? "INFERRED" : !llm && heuristic.signal < 0.5 ? "UNCONFIRMED" : "OBSERVED";
266 + const categories = [...new Set([...source.categories, ...Object.entries(FEED_CHANNELS).filter(([, cats]) => cats.some((c) => source.categories.includes(c))).map(([ch]) => ch)])];
267 + const keywords = [...new Set([...(llm?.keywords ?? []), ...heuristic.keywords])].slice(0, 20);
268 + const id = newId("evt");
269 + const slug = `${slugify(title).slice(0, 70)}-${id.slice(-6)}`;
270 + const processedAt = new Date();
271 + const publishedAt = pickPublishedAt(diff, norm);
272 + const observedFrom = prev.capturedAt;
273 +
274 + await db.insert(events).values({
275 + id,
276 + slug,
277 + sensorId: sensor.id,
278 + sourceId: source.id,
279 + changeId,
280 + oldSnapshotId: prev.id,
281 + newSnapshotId: snapId,
282 + url: sensor.url,
283 + eventType,
284 + title,
285 + summary,
286 + whyItMatters: llm?.why_it_matters ?? null,
287 + importance: prelim.score,
288 + importanceComponents: prelim.components as unknown as Record<string, number>,
289 + confidence,
290 + novelty: nov.novelty,
291 + categories,
292 + keywords,
293 + silentChange,
294 + evidenceLabel,
295 + publishedAt,
296 + observedFrom,
297 + detectedAt,
298 + processedAt,
299 + detectionLatencyMs: publishedAt && detectedAt.getTime() - publishedAt.getTime() < 7 * 86400e3 ? Math.max(0, detectedAt.getTime() - publishedAt.getTime()) : null,
300 + processingLatencyMs: processedAt.getTime() - detectedAt.getTime(),
301 + processingVersion: config.processingVersion,
302 + interpretation: llm ? { model: llm.model, observed: llm.observed, inferred: llm.inferred, who_it_affects: llm.who_it_affects, severity: llm.severity, confidence: llm.confidence, announced: llm.announced, entities: llm.entities } : { model: "heuristics-v1", reasons: heuristic.reasons, facts: heuristic.facts },
303 + });
304 + await db.insert(interpretations).values({ eventId: id, version: 1, model: llm?.model ?? "heuristics-v1", payload: { heuristic: heuristic as unknown as Record<string, unknown>, llm: llm as unknown as Record<string, unknown> | null } });
305 + for (const e of entityIds) await db.insert(eventEntities).values({ eventId: id, entityId: e, role: ent.subject.includes(e) ? "subject" : "mentioned" }).onConflictDoNothing();
306 + await bumpEntityCounters(entityIds, detectedAt);
307 + await db.update(changes).set({ meaningful: true, eventId: id }).where(sql`id = ${changeId}`);
308 + await db.update(sensors).set({ meaningfulChanges: sensor.meaningfulChanges + 1 }).where(sql`id = ${sensor.id}`);
309 + await db.execute(sql`insert into url_history (url, at, kind, snapshot_id, change_id, event_id) values (${sensor.url}, ${detectedAt}, 'event', ${snapId}, ${changeId}, ${id})`);
310 +
311 + const cl = await clusterEvent({ id, sourceId: source.id, sensorId: sensor.id, eventType, entityIds, detectedAt, title, summary, importance: prelim.score, categories });
312 + await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date() }).where(sql`id = ${id}`);
313 +
314 + const entNames = entityIds.length ? await db.execute<{ id: string; name: string; type: string }>(sql`select id, name, type from entities where id = any(${textArray(entityIds)})`) : { rows: [] as { id: string; name: string; type: string }[] };
315 + await publishEvent({
316 + id,
317 + slug,
318 + type: eventType,
319 + title,
320 + summary: summary.slice(0, 280),
321 + importance: prelim.score,
322 + confidence,
323 + novelty: nov.novelty,
324 + silent: silentChange,
325 + evidence: evidenceLabel,
326 + source: { id: source.id, name: source.name, domain: source.domain },
327 + sensor: { id: sensor.id, name: sensor.name, type: sensor.type },
328 + entities: entNames.rows.map((r) => ({ id: r.id, name: r.name, type: r.type })),
329 + categories,
330 + url: sensor.url,
331 + clusterId: cl.clusterId,
332 + detectedAt: detectedAt.toISOString(),
333 + publishedAt: publishedAt?.toISOString() ?? null,
334 + });
335 + m.events.inc({ event_type: eventType, silent: String(silentChange) });
336 + m.processingLatency.observe((Date.now() - detectedAt.getTime()) / 1000);
337 + await bumpDaily({ events: 1, silent_events: silentChange ? 1 : 0 });
338 + log.info({ event: id, source: source.id, type: eventType, importance: prelim.score, confidence, silent: silentChange, llm: llm?.model ?? "heuristics" }, title);
339 + return { id, detectedAt };
340 +}
341 +
342 +function pickPublishedAt(diff: DiffResult, norm: NormalizedContent): Date | null {
343 + if (diff.kind === "list") {
344 + const dates = diff.added.map((i) => i.publishedAt).filter((x): x is string => typeof x === "string" && x.length > 0).map((s) => new Date(s)).filter((d) => !Number.isNaN(d.getTime()));
345 + if (dates.length) return new Date(Math.max(...dates.map((d) => d.getTime())));
346 + }
347 + return norm.publishedAt ?? null;
348 +}
349 +
350 +async function handleMissing(sensor: Sensor, source: Source, obs: Observation, finish: (o: RunOutcome, e?: { error?: string }) => Promise<RunOutcome>): Promise<RunOutcome> {
351 + const state = (sensor.state ?? {}) as Record<string, unknown>;
352 + const missing = (state.missing ?? { count: 0, firstAt: obs.fetchedAt.toISOString(), lastAt: null }) as { count: number; firstAt: string; lastAt: string | null; removedEventAt?: string };
353 + const sepOk = !missing.lastAt || obs.fetchedAt.getTime() - new Date(missing.lastAt).getTime() >= config.deletion.minSeparationMin * 60e3;
354 + if (sepOk) missing.count += 1;
355 + missing.lastAt = obs.fetchedAt.toISOString();
356 + if (sensor.lastSnapshotId && missing.count >= config.deletion.confirmations && !missing.removedEventAt) {
357 + // Confirmed deletion: independent checks separated in time.
358 + missing.removedEventAt = obs.fetchedAt.toISOString();
359 + const id = newId("evt");
360 + const title = `${source.name}: page removed — ${sensor.name}`;
361 + const summary = `${sensor.url} has returned HTTP ${obs.meta.status} on ${missing.count} checks since ${new Date(missing.firstAt).toISOString()}. The last known version is preserved as a snapshot.`;
362 + const imp = computeImportance({ eventType: "page_removed", sourceImportance: sourceImportanceFromTier(sensor.tier, source.importanceWeight), entityImportance: 50, novelty: 80, magnitude: 60, confirmations: 0 });
363 + const slug = `${slugify(title).slice(0, 70)}-${id.slice(-6)}`;
364 + await db.insert(events).values({ id, slug, sensorId: sensor.id, sourceId: source.id, oldSnapshotId: sensor.lastSnapshotId, url: sensor.url, eventType: "page_removed", title, summary, importance: imp.score, importanceComponents: imp.components as unknown as Record<string, number>, confidence: 85, novelty: 80, categories: source.categories, keywords: ["page removed"], silentChange: true, evidenceLabel: "CONFIRMED", detectedAt: obs.fetchedAt, processedAt: new Date(), processingVersion: config.processingVersion, interpretation: { model: "rules", checks: missing.count } });
365 + const ent = await resolveEntities({ sourceId: source.id, text: title, hints: [] });
366 + for (const e of ent.subject) await db.insert(eventEntities).values({ eventId: id, entityId: e, role: "subject" }).onConflictDoNothing();
367 + await db.execute(sql`update urls set status = 'removed' where url = ${sensor.url}`);
368 + await db.execute(sql`insert into url_history (url, at, kind, event_id, note) values (${sensor.url}, ${obs.fetchedAt}, 'removed', ${id}, ${`HTTP ${obs.meta.status}`})`);
369 + const cl = await clusterEvent({ id, sourceId: source.id, sensorId: sensor.id, eventType: "page_removed", entityIds: ent.subject, detectedAt: obs.fetchedAt, title, summary, importance: imp.score, categories: source.categories });
370 + await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date() }).where(sql`id = ${id}`);
371 + await publishEvent({ id, slug, type: "page_removed", title, summary, importance: imp.score, confidence: 85, novelty: 80, silent: true, evidence: "CONFIRMED", source: { id: source.id, name: source.name, domain: source.domain }, sensor: { id: sensor.id, name: sensor.name, type: sensor.type }, entities: [], categories: source.categories, url: sensor.url, clusterId: cl.clusterId, detectedAt: obs.fetchedAt.toISOString(), publishedAt: null });
372 + await bumpDaily({ events: 1, silent_events: 1 });
373 + log.info({ sensor: sensor.id, checks: missing.count }, "page removal confirmed");
374 + } else {
375 + await db.execute(sql`update urls set missing_count = missing_count + 1, status = case when status = 'active' then 'pending_removal' else status end where url = ${sensor.url}`);
376 + }
377 + await db.update(sensors).set({ state: { ...state, missing } }).where(sql`id = ${sensor.id}`);
378 + return finish("missing", { error: `HTTP ${obs.meta.status} (${missing.count}/${config.deletion.confirmations} confirmations)` });
379 +}
380 +
381 +async function touchUrl(sensor: Sensor, source: Source, snapshot: boolean, snapshotId?: string): Promise<void> {
382 + const domain = safeHost(sensor.url);
383 + await db.execute(sql`insert into urls (url, domain, source_id, sensor_id, first_seen_at, last_seen_at, status, snapshot_count)
384 + values (${sensor.url}, ${domain}, ${source.id}, ${sensor.id}, now(), now(), 'active', ${snapshot ? 1 : 0})
385 + on conflict (url) do update set last_seen_at = now(), status = 'active', missing_count = 0, snapshot_count = urls.snapshot_count + ${snapshot ? 1 : 0}, sensor_id = ${sensor.id}`);
386 + if (snapshot && snapshotId) await db.execute(sql`insert into url_history (url, at, kind, snapshot_id) values (${sensor.url}, now(), 'snapshot', ${snapshotId})`);
387 +}
388 +
389 +function safeHost(u: string): string {
390 + try {
391 + return new URL(u).hostname;
392 + } catch {
393 + return "";
394 + }
395 +}
396 +
397 +async function countChanges7d(sensorId: string): Promise<number> {
398 + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from changes where sensor_id = ${sensorId} and detected_at >= now() - interval '7 days'`);
399 + return Number(r.rows[0]?.n ?? 0);
400 +}
401 +async function countEvents7d(sensorId: string): Promise<number> {
402 + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from events where sensor_id = ${sensorId} and detected_at >= now() - interval '7 days'`);
403 + return Number(r.rows[0]?.n ?? 0);
404 +}
added apps/engine/src/redis.ts +41 −0
@@ -0,0 +1,41 @@
1 +import Redis from "ioredis";
2 +import { config, log } from "./config";
3 +
4 +let redis: Redis | null = null;
5 +
6 +export function getRedis(): Redis {
7 + if (!redis) {
8 + redis = new Redis(config.redisUrl, { maxRetriesPerRequest: 3, lazyConnect: false, enableOfflineQueue: true });
9 + redis.on("error", (e) => log.warn({ err: e.message }, "redis error"));
10 + }
11 + return redis;
12 +}
13 +
14 +export const STREAM_EVENTS = "ws:events";
15 +export const CHANNEL_LIVE = "ws:live";
16 +export const STREAM_CHANGES = "ws:changes";
17 +
18 +/** Publish a compact event payload to the stream (durable) and the pub/sub channel (realtime). */
19 +export async function publishEvent(payload: Record<string, unknown>): Promise<void> {
20 + const r = getRedis();
21 + const json = JSON.stringify(payload);
22 + try {
23 + await r.xadd(STREAM_EVENTS, "MAXLEN", "~", "20000", "*", "event", json);
24 + await r.publish(CHANNEL_LIVE, json);
25 + } catch (e) {
26 + log.warn({ err: (e as Error).message }, "publish failed");
27 + }
28 +}
29 +
30 +export async function publishChange(payload: Record<string, unknown>): Promise<void> {
31 + try {
32 + await getRedis().xadd(STREAM_CHANGES, "MAXLEN", "~", "5000", "*", "change", JSON.stringify(payload));
33 + } catch {
34 + // best effort
35 + }
36 +}
37 +
38 +export async function closeRedis(): Promise<void> {
39 + if (redis) await redis.quit().catch(() => undefined);
40 + redis = null;
41 +}
added apps/engine/src/registry.ts +179 −0
@@ -0,0 +1,179 @@
1 +import { readFileSync } from "node:fs";
2 +import YAML from "yaml";
3 +import { z } from "zod";
4 +import { SENSOR_TYPES, TIERS, newId, slugify, sourceImportanceFromTier } from "@websensor/core";
5 +import { db, discoveryCandidates, entities, entityAliases, eq, sensors, sourceEntities, sources, sql, textArray } from "@websensor/db";
6 +import { discoverDomain } from "@websensor/connectors";
7 +import { config, log } from "./config";
8 +
9 +/**
10 + * Source registry: `config/sources.yaml` is the seed (organizations + curated sensors).
11 + * Booleans under `discover:` only allow discovery; every candidate is validated by a real
12 + * fetch+parse before becoming a sensor.
13 + */
14 +const sensorSchema = z.object({
15 + id: z.string().optional(),
16 + name: z.string(),
17 + url: z.string().url(),
18 + type: z.enum(SENSOR_TYPES),
19 + connector: z.string().default("http"),
20 + tier: z.enum(TIERS).optional(),
21 + interval: z.number().int().positive().optional(),
22 + weight: z.number().positive().optional(),
23 + config: z.record(z.string(), z.unknown()).default({}),
24 +});
25 +
26 +const sourceSchema = z.object({
27 + id: z.string(),
28 + name: z.string(),
29 + domain: z.string(),
30 + homepage: z.string().url().optional(),
31 + description: z.string().optional(),
32 + categories: z.array(z.string()).default([]),
33 + tier: z.enum(TIERS).default("B"),
34 + weight: z.number().positive().default(1),
35 + entity_type: z.string().default("organization"),
36 + aliases: z.array(z.string()).default([]),
37 + products: z.array(z.object({ id: z.string().optional(), name: z.string(), type: z.string().default("product"), aliases: z.array(z.string()).default([]) })).default([]),
38 + discover: z.object({ rss: z.boolean().optional(), sitemap: z.boolean().optional(), status: z.boolean().optional(), pages: z.boolean().optional() }).default({}),
39 + fallback: z.object({ firecrawl: z.boolean().optional(), scrapfly: z.boolean().optional() }).default({}),
40 + sensors: z.array(sensorSchema).default([]),
41 + notes: z.string().optional(),
42 + enabled: z.boolean().default(true),
43 +});
44 +export type SourceSeed = z.infer<typeof sourceSchema>;
45 +
46 +export function loadSeeds(file = config.sourcesFile): SourceSeed[] {
47 + const raw = YAML.parse(readFileSync(file, "utf8")) as { sources: unknown[] };
48 + const out: SourceSeed[] = [];
49 + for (const s of raw.sources) {
50 + const parsed = sourceSchema.safeParse(s);
51 + if (!parsed.success) {
52 + log.error({ issues: parsed.error.issues, source: (s as { id?: string }).id }, "invalid source seed");
53 + continue;
54 + }
55 + out.push(parsed.data);
56 + }
57 + return out;
58 +}
59 +
60 +export async function syncRegistry(seeds = loadSeeds()): Promise<{ sources: number; sensors: number }> {
61 + let nSensors = 0;
62 + for (const s of seeds) {
63 + await db
64 + .insert(sources)
65 + .values({ id: s.id, name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled })
66 + .onConflictDoUpdate({ target: sources.id, set: { name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, updatedAt: new Date() } });
67 +
68 + // Organization entity + aliases
69 + const entId = `org_${s.id}`;
70 + await db
71 + .insert(entities)
72 + .values({ id: entId, name: s.name, type: s.entity_type, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories })
73 + .onConflictDoUpdate({ target: entities.id, set: { name: s.name, type: s.entity_type, domain: s.domain, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories } });
74 + await db.insert(sourceEntities).values({ sourceId: s.id, entityId: entId }).onConflictDoNothing();
75 + for (const alias of new Set([s.name, s.domain, s.domain.replace(/^www\./, ""), ...s.aliases])) {
76 + await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: entId }).onConflictDoNothing();
77 + }
78 + for (const p of s.products) {
79 + const pid = p.id ?? `prd_${s.id}_${slugify(p.name)}`;
80 + await db
81 + .insert(entities)
82 + .values({ id: pid, name: p.name, type: p.type, parentId: entId, importance: Math.max(30, sourceImportanceFromTier(s.tier, s.weight) - 10), categories: s.categories, domain: s.domain })
83 + .onConflictDoUpdate({ target: entities.id, set: { name: p.name, type: p.type, parentId: entId } });
84 + await db.insert(sourceEntities).values({ sourceId: s.id, entityId: pid }).onConflictDoNothing();
85 + for (const alias of new Set([p.name, ...p.aliases])) await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: pid }).onConflictDoNothing();
86 + await db.execute(sql`insert into entity_relations (from_id, relation, to_id) values (${entId}, 'owns', ${pid}) on conflict do nothing`);
87 + }
88 +
89 + const seedIds: string[] = [];
90 + for (const sen of s.sensors) {
91 + const id = sen.id ?? `${s.id}_${slugify(sen.name)}`;
92 + seedIds.push(id);
93 + const cfg = { ...sen.config, seed: true };
94 + await db
95 + .insert(sensors)
96 + .values({ id, sourceId: s.id, name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier: sen.tier ?? s.tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null })
97 + .onConflictDoUpdate({ target: sensors.id, set: { name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier: sen.tier ?? s.tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null, enabled: true, updatedAt: new Date() } });
98 + nSensors++;
99 + }
100 + // Seed sensors removed from the YAML are disabled (history kept), discovery-created ones are untouched.
101 + await db.execute(sql`update sensors set enabled = false, updated_at = now() where source_id = ${s.id} and enabled and (config->>'seed') = 'true' and not (id = any(${textArray(seedIds)}))`);
102 + }
103 + log.info({ sources: seeds.length, sensors: nSensors }, "registry synced");
104 + return { sources: seeds.length, sensors: nSensors };
105 +}
106 +
107 +/**
108 + * Discovery: for each enabled source whose `discover` flags allow it, probe the domain,
109 + * store candidates and promote validated feeds / sitemaps / statuspages to sensors that do
110 + * not already exist for that URL.
111 + */
112 +export async function runDiscovery(opts: { onlyMissing?: boolean; sourceIds?: string[] } = {}): Promise<number> {
113 + const rows = await db.select().from(sources).where(eq(sources.enabled, true));
114 + let promoted = 0;
115 + const targets = rows.filter((r) => !opts.sourceIds || opts.sourceIds.includes(r.id));
116 + let i = 0;
117 + const workers = Array.from({ length: 4 }, async () => {
118 + while (i < targets.length) {
119 + const src = targets[i++]!;
120 + const d = src.discover as { rss?: boolean; sitemap?: boolean; status?: boolean; pages?: boolean };
121 + if (!d.rss && !d.sitemap && !d.status && !d.pages) continue;
122 + const existing = await db.select({ url: sensors.url, type: sensors.type }).from(sensors).where(eq(sensors.sourceId, src.id));
123 + const lastRun = (src.notes ?? "").match(/discovered_at=(\S+)/)?.[1];
124 + if (opts.onlyMissing && lastRun && Date.now() - new Date(lastRun).getTime() < config.discovery.intervalDays * 86400e3) continue;
125 + const started = Date.now();
126 + let found;
127 + try {
128 + found = await discoverDomain(src.domain, { probePages: Boolean(d.pages), sensorIdForLogs: `discover_${src.id}` });
129 + } catch (e) {
130 + log.warn({ source: src.id, err: (e as Error).message }, "discovery failed");
131 + continue;
132 + }
133 + const existingUrls = new Set(existing.map((e) => e.url.replace(/\/$/, "")));
134 + const hasFeed = existing.some((e) => e.type === "RSS" || e.type === "ATOM");
135 + let feedsAdded = 0;
136 + for (const f of found) {
137 + await db
138 + .insert(discoveryCandidates)
139 + .values({ id: newId("cand"), sourceId: src.id, url: f.url, kind: f.type, evidence: f.evidence, score: { value: f.value, itemCount: f.itemCount ?? null, title: f.title ?? null } })
140 + .onConflictDoNothing();
141 + if (existingUrls.has(f.url.replace(/\/$/, ""))) continue;
142 + const allowed = (f.connector === "rss" && d.rss) || (f.connector === "sitemap" && d.sitemap) || (f.connector === "statuspage" && d.status) || (f.connector === "http" && d.pages);
143 + if (!allowed) continue;
144 + // Promotion policy: feeds (max 3 per source, best first), one sitemap, one statuspage, pricing/changelog/security pages.
145 + if (f.connector === "rss" && feedsAdded + (hasFeed ? 1 : 0) >= 3) continue;
146 + if (f.connector === "sitemap" && existing.some((e) => e.type === "SITEMAP")) continue;
147 + if (f.connector === "statuspage" && existing.some((e) => e.type === "STATUSPAGE")) continue;
148 + if (f.connector === "http" && !/pricing|changelog|security|releases/i.test(f.url)) continue;
149 + const name = f.connector === "rss" ? feedName(f.url, f.title) : f.connector === "sitemap" ? "sitemap" : f.connector === "statuspage" ? "status" : new URL(f.url).pathname.replace(/\W+/g, " ").trim() || "page";
150 + const id = `${src.id}_${slugify(name)}`.slice(0, 80);
151 + const tier = f.connector === "statuspage" ? "S" : f.connector === "sitemap" ? (src.tier === "S" ? "A" : src.tier) : src.tier;
152 + await db
153 + .insert(sensors)
154 + .values({ id, sourceId: src.id, name, url: f.url, type: f.type, connector: f.connector, tier, config: f.connector === "sitemap" ? { maxChildren: 4, maxUrls: 3000 } : {} })
155 + .onConflictDoNothing();
156 + await db.update(discoveryCandidates).set({ status: "promoted" }).where(sql`source_id = ${src.id} and url = ${f.url}`);
157 + existingUrls.add(f.url.replace(/\/$/, ""));
158 + if (f.connector === "rss") feedsAdded++;
159 + promoted++;
160 + }
161 + const notes = ((src.notes ?? "").replace(/\s*discovered_at=\S+/, "") + ` discovered_at=${new Date().toISOString()}`).trim();
162 + await db.update(sources).set({ notes, robotsCheckedAt: new Date(), updatedAt: new Date() }).where(eq(sources.id, src.id));
163 + log.info({ source: src.id, candidates: found.length, ms: Date.now() - started }, "discovery done");
164 + }
165 + });
166 + await Promise.all(workers);
167 + log.info({ promoted }, "discovery promoted sensors");
168 + return promoted;
169 +}
170 +
171 +function feedName(url: string, title?: string): string {
172 + const p = new URL(url).pathname.toLowerCase();
173 + if (/blog/.test(p)) return "blog feed";
174 + if (/news|press/.test(p)) return "news feed";
175 + if (/release|changelog/.test(p)) return "changelog feed";
176 + if (/security|advisor/.test(p)) return "security feed";
177 + if (title) return slugify(title).replace(/-/g, " ").slice(0, 40) + " feed";
178 + return "feed";
179 +}
added apps/engine/src/scheduler.ts +162 −0
@@ -0,0 +1,162 @@
1 +import { db, sensors, sources, sql, type Sensor, type Source } from "@websensor/db";
2 +import { config, log } from "./config";
3 +import { m } from "./metrics";
4 +import { runSensor } from "./pipeline";
5 +
6 +/**
7 + * Scheduler: claims due sensors with `FOR UPDATE SKIP LOCKED` (safe with several engine
8 + * processes), enforces global + per-host concurrency, and runs the pipeline. A claim moves
9 + * `next_check_at` 10 minutes ahead as a lease; the pipeline then sets the real next time.
10 + */
11 +export class Scheduler {
12 + private inflight = 0;
13 + private perHost = new Map<string, number>();
14 + private stopped = false;
15 + private sourceCache = new Map<string, Source>();
16 + private sourceCacheAt = 0;
17 + private timer: NodeJS.Timeout | null = null;
18 +
19 + async start(): Promise<void> {
20 + log.info({ concurrency: config.fetchConcurrency, perHost: config.perHostConcurrency }, "scheduler started");
21 + const tick = async (): Promise<void> => {
22 + if (this.stopped) return;
23 + try {
24 + await this.tick();
25 + } catch (e) {
26 + log.error({ err: (e as Error).message }, "scheduler tick failed");
27 + }
28 + this.timer = setTimeout(tick, this.inflight >= config.fetchConcurrency ? 500 : 1500);
29 + };
30 + void tick();
31 + }
32 +
33 + async stop(): Promise<void> {
34 + this.stopped = true;
35 + if (this.timer) clearTimeout(this.timer);
36 + const deadline = Date.now() + 30_000;
37 + while (this.inflight > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
38 + }
39 +
40 + private async refreshSources(): Promise<void> {
41 + if (Date.now() - this.sourceCacheAt < 60_000 && this.sourceCache.size) return;
42 + const rows = await db.select().from(sources);
43 + this.sourceCache = new Map(rows.map((r) => [r.id, r]));
44 + this.sourceCacheAt = Date.now();
45 + }
46 +
47 + private async tick(): Promise<void> {
48 + await this.refreshSources();
49 + const due = await db.execute<{ n: string }>(sql`select count(*)::text as n from sensors where enabled and next_check_at <= now()`);
50 + m.queueDue.set(Number(due.rows[0]?.n ?? 0));
51 + const slots = config.fetchConcurrency - this.inflight;
52 + if (slots <= 0) return;
53 + const claimed = await db.execute<Sensor>(sql`
54 + update sensors set next_check_at = now() + interval '10 minutes'
55 + where id in (select id from sensors where enabled and next_check_at <= now() order by next_check_at asc limit ${slots} for update skip locked)
56 + returning *`);
57 + for (const row of claimed.rows) {
58 + const sensor = normalizeRow(row as unknown as Record<string, unknown>);
59 + const host = safeHost(sensor.url);
60 + const hostBusy = (this.perHost.get(host) ?? 0) >= config.perHostConcurrency;
61 + if (hostBusy) {
62 + await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 5000) }).where(sql`id = ${sensor.id}`);
63 + continue;
64 + }
65 + const source = this.sourceCache.get(sensor.sourceId);
66 + if (!source || !source.enabled) {
67 + await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 3600e3) }).where(sql`id = ${sensor.id}`);
68 + continue;
69 + }
70 + this.inflight++;
71 + this.perHost.set(host, (this.perHost.get(host) ?? 0) + 1);
72 + m.inflight.set(this.inflight);
73 + void runSensor(sensor, source)
74 + .catch(async (e) => {
75 + log.error({ sensor: sensor.id, err: (e as Error).stack ?? (e as Error).message }, "pipeline crashed");
76 + await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 15 * 60e3), lastError: `pipeline: ${(e as Error).message}`.slice(0, 500), consecutiveErrors: sensor.consecutiveErrors + 1, health: "DEGRADED" }).where(sql`id = ${sensor.id}`).catch(() => undefined);
77 + })
78 + .finally(() => {
79 + this.inflight--;
80 + this.perHost.set(host, Math.max(0, (this.perHost.get(host) ?? 1) - 1));
81 + m.inflight.set(this.inflight);
82 + });
83 + }
84 + }
85 +}
86 +
87 +/** Raw `returning *` rows come back snake_case; map to the Drizzle camelCase shape. */
88 +function normalizeRow(r: Record<string, unknown>): Sensor {
89 + const d = (v: unknown): Date | null => (v ? new Date(v as string) : null);
90 + return {
91 + id: r.id as string,
92 + sourceId: r.source_id as string,
93 + name: r.name as string,
94 + url: r.url as string,
95 + type: r.type as string,
96 + connector: r.connector as string,
97 + tier: r.tier as string,
98 + importanceWeight: Number(r.importance_weight),
99 + config: (r.config ?? {}) as Record<string, unknown>,
100 + baseIntervalSeconds: (r.base_interval_seconds as number | null) ?? null,
101 + enabled: Boolean(r.enabled),
102 + health: r.health as string,
103 + nextCheckAt: d(r.next_check_at) ?? new Date(),
104 + lastCheckAt: d(r.last_check_at),
105 + lastChangeAt: d(r.last_change_at),
106 + lastEventAt: d(r.last_event_at),
107 + lastStatus: (r.last_status as number | null) ?? null,
108 + lastError: (r.last_error as string | null) ?? null,
109 + etag: (r.etag as string | null) ?? null,
110 + lastModified: (r.last_modified as string | null) ?? null,
111 + state: (r.state as Record<string, unknown> | null) ?? null,
112 + lastSnapshotId: (r.last_snapshot_id as string | null) ?? null,
113 + consecutiveErrors: Number(r.consecutive_errors ?? 0),
114 + totalRuns: Number(r.total_runs ?? 0),
115 + totalNotModified: Number(r.total_not_modified ?? 0),
116 + rawChanges: Number(r.raw_changes ?? 0),
117 + meaningfulChanges: Number(r.meaningful_changes ?? 0),
118 + avgLatencyMs: (r.avg_latency_ms as number | null) ?? null,
119 + createdAt: d(r.created_at) ?? new Date(),
120 + updatedAt: d(r.updated_at) ?? new Date(),
121 + };
122 +}
123 +
124 +function safeHost(u: string): string {
125 + try {
126 + return new URL(u).hostname;
127 + } catch {
128 + return u;
129 + }
130 +}
131 +
132 +/** Roll up connector health from the last 24 h of runs. */
133 +export async function rollupConnectorHealth(): Promise<void> {
134 + await db.execute(sql`
135 + insert into connector_health (connector, status, runs_24h, errors_24h, success_rate, avg_latency_ms, changes_24h, events_24h, last_success_at, last_error_at, last_error, http_codes, updated_at)
136 + select s.connector,
137 + case when count(r.id) = 0 then 'UP'
138 + when sum(case when r.outcome in ('error','parse_error') then 1 else 0 end)::float / count(r.id) > 0.5 then 'ERROR'
139 + when bool_or(r.outcome = 'rate_limited') and max(r.started_at) filter (where r.outcome = 'rate_limited') > now() - interval '1 hour' then 'RATE_LIMITED'
140 + when sum(case when r.outcome in ('error','parse_error') then 1 else 0 end)::float / count(r.id) > 0.15 then 'DEGRADED'
141 + else 'UP' end,
142 + count(r.id)::int,
143 + sum(case when r.outcome in ('error','parse_error','rate_limited') then 1 else 0 end)::int,
144 + case when count(r.id) = 0 then null else 1 - sum(case when r.outcome in ('error','parse_error','rate_limited') then 1 else 0 end)::float / count(r.id) end,
145 + avg(r.duration_ms)::int,
146 + (select count(*) from changes c join sensors s2 on s2.id = c.sensor_id where s2.connector = s.connector and c.detected_at >= now() - interval '24 hours')::int,
147 + (select count(*) from events e join sensors s3 on s3.id = e.sensor_id where s3.connector = s.connector and e.detected_at >= now() - interval '24 hours')::int,
148 + max(r.started_at) filter (where r.outcome not in ('error','parse_error','rate_limited')),
149 + max(r.started_at) filter (where r.outcome in ('error','parse_error','rate_limited')),
150 + (array_agg(r.error order by r.started_at desc) filter (where r.error is not null))[1],
151 + coalesce((select jsonb_object_agg(code, n) from (select coalesce(r2.http_status, 0)::text as code, count(*) as n from sensor_runs r2 join sensors s4 on s4.id = r2.sensor_id where s4.connector = s.connector and r2.started_at >= now() - interval '24 hours' group by 1) x), '{}'::jsonb),
152 + now()
153 + from sensors s left join sensor_runs r on r.sensor_id = s.id and r.started_at >= now() - interval '24 hours'
154 + group by s.connector
155 + on conflict (connector) do update set status = excluded.status, runs_24h = excluded.runs_24h, errors_24h = excluded.errors_24h, success_rate = excluded.success_rate, avg_latency_ms = excluded.avg_latency_ms, changes_24h = excluded.changes_24h, events_24h = excluded.events_24h, last_success_at = excluded.last_success_at, last_error_at = excluded.last_error_at, last_error = excluded.last_error, http_codes = excluded.http_codes, updated_at = now()`);
156 +}
157 +
158 +/** Retention: raw fetch logs are short-lived; snapshots/events are kept. */
159 +export async function pruneOldRuns(): Promise<void> {
160 + await db.execute(sql`delete from sensor_runs where started_at < now() - interval '14 days' and outcome in ('unchanged','not_modified')`);
161 + await db.execute(sql`delete from sensor_runs where started_at < now() - interval '60 days'`);
162 +}
added apps/engine/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added apps/web/README.md +18 −0
@@ -0,0 +1,18 @@
1 +# @websensor/web
2 +
3 +Next.js 16 frontend for WebSensor.io. Served on port 8261 (loopback) behind the Fastify gateway (`apps/api`, port 8260), which proxies every non-`/api` path here.
4 +
5 +## Environment
6 +
7 +| Variable | Default | Purpose |
8 +|---|---|---|
9 +| `API_URL` | `http://127.0.0.1:8260` | Gateway base URL used by Server Components (loopback). |
10 +| `NEXT_PUBLIC_API_URL` | *(empty → same origin)* | Base URL used by client components in dev when the site is not served through the gateway. |
11 +| `NEXT_PUBLIC_SITE_URL` | `https://www.websensor.io` | Canonical URL for metadata, sitemap, JSON-LD. |
12 +| `NEXT_TELEMETRY_DISABLED` | `1` | Recommended in production. |
13 +
14 +## Scripts
15 +
16 +`pnpm dev` (8261) · `pnpm build` · `pnpm start` · `pnpm typecheck` · `pnpm lint`
17 +
18 +All data pages are `force-dynamic`; API failures degrade to empty states so the build never depends on a running gateway.
added apps/web/eslint.config.mjs +6 −0
@@ -0,0 +1,6 @@
1 +import nextVitals from "eslint-config-next/core-web-vitals";
2 +import nextTs from "eslint-config-next/typescript";
3 +
4 +const eslintConfig = [...nextVitals, ...nextTs, { ignores: [".next/**", "node_modules/**"] }];
5 +
6 +export default eslintConfig;
added apps/web/next.config.ts +23 −0
@@ -0,0 +1,23 @@
1 +import type { NextConfig } from "next";
2 +
3 +const nextConfig: NextConfig = {
4 + reactStrictMode: true,
5 + agentRules: false,
6 + poweredByHeader: false,
7 + transpilePackages: ["@websensor/core"],
8 + async headers() {
9 + return [
10 + {
11 + source: "/(.*)",
12 + headers: [
13 + { key: "X-Content-Type-Options", value: "nosniff" },
14 + { key: "X-Frame-Options", value: "DENY" },
15 + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
16 + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
17 + ],
18 + },
19 + ];
20 + },
21 +};
22 +
23 +export default nextConfig;
added apps/web/package.json +32 −0
@@ -0,0 +1,32 @@
1 +{
2 + "name": "@websensor/web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev -p 8261",
7 + "build": "next build",
8 + "start": "next start -p 8261 -H 127.0.0.1",
9 + "lint": "eslint",
10 + "typecheck": "tsc --noEmit",
11 + "test": "vitest run --passWithNoTests"
12 + },
13 + "dependencies": {
14 + "@websensor/core": "workspace:*",
15 + "lucide-react": "^1.0.0",
16 + "next": "16.3.4",
17 + "next-themes": "^0.4.6",
18 + "react": "19.2.8",
19 + "react-dom": "19.2.8"
20 + },
21 + "devDependencies": {
22 + "@tailwindcss/postcss": "^4",
23 + "@types/node": "^24.0.0",
24 + "@types/react": "^19",
25 + "@types/react-dom": "^19",
26 + "eslint": "^9",
27 + "eslint-config-next": "16.3.4",
28 + "tailwindcss": "^4",
29 + "typescript": "^5.9.3",
30 + "vitest": "^3.2.0"
31 + }
32 +}
added apps/web/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1 +const config = { plugins: { "@tailwindcss/postcss": {} } };
2 +export default config;
added apps/web/src/app/alerts/alerts.tsx +186 −0
@@ -0,0 +1,186 @@
1 +"use client";
2 +
3 +import { Bell, BellRing, Trash2 } from "lucide-react";
4 +import Link from "next/link";
5 +import { useCallback, useEffect, useState } from "react";
6 +import { EVENT_TYPES } from "@websensor/core/client";
7 +import { LiveDot } from "@/components/live-feed";
8 +import { useMounted } from "@/components/theme";
9 +import { Chip, Empty, Panel, Score } from "@/components/ui";
10 +import type { Alert, AlertRule, LiveEvent } from "@/lib/api";
11 +import { relTime, typeLabel } from "@/lib/format";
12 +import { ownerFetch } from "@/lib/owner";
13 +import { useLive } from "@/lib/use-live";
14 +
15 +function matches(rule: AlertRule, e: LiveEvent): boolean {
16 + if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false;
17 + if (rule.silent_only && !e.silent) return false;
18 + if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false;
19 + if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false;
20 + if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false;
21 + if (rule.sources?.length && !rule.sources.includes(e.source?.id)) return false;
22 + if (rule.keywords?.length) {
23 + const hay = `${e.title} ${e.summary}`.toLowerCase();
24 + if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false;
25 + }
26 + return true;
27 +}
28 +
29 +export function Alerts() {
30 + const [alerts, setAlerts] = useState<Alert[] | null>(null);
31 + const [fired, setFired] = useState<{ alert: Alert; event: LiveEvent; at: number }[]>([]);
32 + const mounted = useMounted();
33 + const [permOverride, setPerm] = useState<NotificationPermission | null>(null);
34 + const perm: NotificationPermission | "unsupported" = !mounted ? "default" : permOverride ?? (typeof Notification === "undefined" ? "unsupported" : Notification.permission);
35 + const [form, setForm] = useState<{ name: string; importance_min: number; event_types: string[]; entities: string; sources: string; keywords: string; silent_only: boolean }>({ name: "", importance_min: 70, event_types: [], entities: "", sources: "", keywords: "", silent_only: false });
36 + const [err, setErr] = useState<string | null>(null);
37 +
38 + const load = useCallback(
39 + () =>
40 + ownerFetch<{ items: Alert[] }>("/api/v1/alerts")
41 + .then((r) => setAlerts(r.items))
42 + .catch((e: Error) => {
43 + setErr(e.message);
44 + setAlerts([]);
45 + }),
46 + [],
47 + );
48 + useEffect(() => {
49 + void load();
50 + }, [load]);
51 +
52 + const status = useLive(["events:global"], (e) => {
53 + for (const a of alerts ?? []) {
54 + if (!a.enabled || !matches(a.rule, e)) continue;
55 + setFired((prev) => [{ alert: a, event: e, at: Date.now() }, ...prev].slice(0, 50));
56 + if (typeof Notification !== "undefined" && Notification.permission === "granted") {
57 + try {
58 + const n = new Notification(`${e.source?.name ?? "WebSensor"} · ${Math.round(e.importance)}`, { body: e.title, tag: e.id, icon: "/icon.svg" });
59 + n.onclick = () => window.open(`/event/${e.slug}`, "_blank");
60 + } catch {
61 + // notifications unavailable
62 + }
63 + }
64 + }
65 + });
66 +
67 + const create = async (): Promise<void> => {
68 + const split = (s: string): string[] | undefined => s.split(",").map((x) => x.trim()).filter(Boolean).length ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
69 + const rule: AlertRule = { importance_min: form.importance_min, event_types: form.event_types.length ? form.event_types : undefined, entities: split(form.entities), sources: split(form.sources), keywords: split(form.keywords), silent_only: form.silent_only || undefined };
70 + try {
71 + await ownerFetch("/api/v1/alerts", { method: "POST", body: JSON.stringify({ name: form.name.trim() || "Alert", rule, channel: "web" }) });
72 + setForm({ name: "", importance_min: 70, event_types: [], entities: "", sources: "", keywords: "", silent_only: false });
73 + await load();
74 + } catch (e) {
75 + setErr((e as Error).message);
76 + }
77 + };
78 +
79 + return (
80 + <div className="grid gap-4 lg:grid-cols-[380px_1fr]">
81 + <aside className="flex flex-col gap-4">
82 + <Panel title="New rule">
83 + <form
84 + className="flex flex-col gap-2 text-[12.5px]"
85 + onSubmit={(e) => {
86 + e.preventDefault();
87 + void create();
88 + }}
89 + >
90 + <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name" className="h-8 rounded-md border border-line bg-panel px-2" />
91 + <label className="flex items-center justify-between gap-2">
92 + <span className="text-fg-muted">Importance ≥ <span className="font-mono">{form.importance_min}</span></span>
93 + <input type="range" min={0} max={100} value={form.importance_min} onChange={(e) => setForm({ ...form, importance_min: Number(e.target.value) })} className="w-40 accent-[var(--signal)]" />
94 + </label>
95 + <label className="flex items-center gap-2">
96 + <input type="checkbox" checked={form.silent_only} onChange={(e) => setForm({ ...form, silent_only: e.target.checked })} className="accent-[var(--silent)]" />
97 + <span className="text-silent">Silent changes only</span>
98 + </label>
99 + <div>
100 + <div className="label mb-1">Event types</div>
101 + <div className="flex max-h-32 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5">
102 + {Object.entries(EVENT_TYPES).map(([k, v]) => {
103 + const on = form.event_types.includes(k);
104 + return (
105 + <button key={k} type="button" onClick={() => setForm({ ...form, event_types: on ? form.event_types.filter((x) => x !== k) : [...form.event_types, k] })} className={`rounded-sm border px-1.5 py-px text-[10.5px] ${on ? "border-signal/50 bg-signal-soft text-signal" : "border-line text-fg-muted hover:text-fg"}`}>
106 + {v.label}
107 + </button>
108 + );
109 + })}
110 + </div>
111 + </div>
112 + <input value={form.entities} onChange={(e) => setForm({ ...form, entities: e.target.value })} placeholder="Entity ids, comma-separated (org_openai, prd_openai_api)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />
113 + <input value={form.sources} onChange={(e) => setForm({ ...form, sources: e.target.value })} placeholder="Source ids (openai, cisa)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />
114 + <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="Keywords (pricing, CVE, outage)" className="h-8 rounded-md border border-line bg-panel px-2" />
115 + <button type="submit" className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-line bg-panel-2 px-3 hover:border-line-strong"><Bell className="size-3.5" /> Create rule</button>
116 + {err && <p className="text-[11px] text-danger">{err}</p>}
117 + </form>
118 + </Panel>
119 + <Panel title="Delivery">
120 + <div className="flex flex-col gap-2 text-[12.5px]">
121 + <div className="flex items-center justify-between">
122 + <span>Web (this browser)</span>
123 + <Chip tone="ok">active</Chip>
124 + </div>
125 + <div className="flex items-center justify-between">
126 + <span>Browser notifications</span>
127 + {perm === "granted" ? <Chip tone="ok">granted</Chip> : perm === "unsupported" ? <Chip>unsupported</Chip> : perm === "denied" ? <Chip tone="danger">denied</Chip> : <button type="button" onClick={() => Notification.requestPermission().then(setPerm)} className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-[12px]">Enable</button>}
128 + </div>
129 + {["Email", "Push", "Webhook", "Slack", "Discord"].map((c) => (
130 + <div key={c} className="flex items-center justify-between text-fg-muted">
131 + <span>{c}</span>
132 + <Chip>planned</Chip>
133 + </div>
134 + ))}
135 + </div>
136 + </Panel>
137 + </aside>
138 + <div className="flex flex-col gap-4">
139 + <Panel title={`Rules · ${alerts?.length ?? 0}`} dense>
140 + {alerts === null ? (
141 + <Empty>Loading…</Empty>
142 + ) : alerts.length ? (
143 + <ul className="divide-y divide-line">
144 + {alerts.map((a) => (
145 + <li key={a.id} className="flex items-start gap-3 px-3 py-2 text-[13px]">
146 + <BellRing className="mt-0.5 size-4 text-signal" />
147 + <div className="min-w-0 flex-1">
148 + <div className="font-medium">{a.name}</div>
149 + <div className="mt-0.5 flex flex-wrap gap-1">
150 + {a.rule.importance_min !== undefined && <Chip>importance ≥ {a.rule.importance_min}</Chip>}
151 + {a.rule.silent_only && <Chip tone="silent">silent only</Chip>}
152 + {a.rule.event_types?.map((t) => <Chip key={t}>{typeLabel(t)}</Chip>)}
153 + {a.rule.entities?.map((t) => <Chip key={t} tone="signal">{t}</Chip>)}
154 + {a.rule.sources?.map((t) => <Chip key={t} tone="info">{t}</Chip>)}
155 + {a.rule.keywords?.map((t) => <Chip key={t}>“{t}”</Chip>)}
156 + </div>
157 + </div>
158 + <button type="button" aria-label="Delete" onClick={() => ownerFetch(`/api/v1/alerts/${a.id}`, { method: "DELETE" }).then(load)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button>
159 + </li>
160 + ))}
161 + </ul>
162 + ) : (
163 + <Empty>No rules yet.</Empty>
164 + )}
165 + </Panel>
166 + <Panel title={<span className="flex items-center gap-3">Fired in this session <LiveDot status={status} /></span>} dense>
167 + {fired.length ? (
168 + <ul className="divide-y divide-line">
169 + {fired.map((f, i) => (
170 + <li key={`${f.event.id}-${i}`} className="flex items-center gap-3 px-3 py-2 text-[13px] animate-fade-in">
171 + <Score value={f.event.importance} size="sm" />
172 + <div className="min-w-0 flex-1">
173 + <Link href={`/event/${f.event.slug}`} className="block truncate font-medium hover:underline">{f.event.title}</Link>
174 + <div className="text-[11px] text-fg-subtle">rule “{f.alert.name}” · {f.event.source?.name} · {relTime(new Date(f.at))}</div>
175 + </div>
176 + </li>
177 + ))}
178 + </ul>
179 + ) : (
180 + <Empty>Matching events will appear here while this page is open.</Empty>
181 + )}
182 + </Panel>
183 + </div>
184 + </div>
185 + );
186 +}
added apps/web/src/app/alerts/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { Metadata } from "next";
2 +import { PageHeader } from "@/components/ui";
3 +import { Alerts } from "./alerts";
4 +
5 +export const metadata: Metadata = { title: "Alerts", description: "Alert rules on importance, event type, entity, source, keyword or silent changes. Delivered in this browser today; email, webhooks, Slack and Discord are planned.", robots: { index: false } };
6 +
7 +export default function AlertsPage() {
8 + return (
9 + <>
10 + <PageHeader kicker="Rules evaluated on the live stream" title="Alerts" description="Define conditions; while this page (or any WebSensor tab with alerts enabled) is open, matching events trigger a toast and a browser notification. Email, push, webhook, Slack and Discord delivery are planned." />
11 + <Alerts />
12 + </>
13 + );
14 +}
added apps/web/src/app/api/page.tsx +131 −0
@@ -0,0 +1,131 @@
1 +import type { Metadata } from "next";
2 +import { Code } from "@/components/code";
3 +import { PageHeader, Panel, Table, Td } from "@/components/ui";
4 +import { SITE_URL } from "@/lib/api";
5 +
6 +export const metadata: Metadata = { title: "API", description: "WebSensor REST API, RSS feed and WebSocket live stream. Public, no key required in phase 1." };
7 +
8 +const ENDPOINTS: [string, string][] = [
9 + ["GET /api/v1/events", "List events. Filters: after, before, category, entity, source, domain, sensor, cluster, importance_min, confidence_min, event_type (comma list), silent_change, q, limit (≤200), cursor, order=recent|importance"],
10 + ["GET /api/v1/events/{id|slug}", "Event detail: related events, cluster, change summary, interpretation versions, snapshots, sensor reliability"],
11 + ["GET /api/v1/changes/{id}", "Raw change with stored heuristic and the unified patch"],
12 + ["GET /api/v1/snapshots/{id}", "Snapshot metadata + canonical content; ?raw=1 streams the original body"],
13 + ["GET /api/v1/snapshots/compare?a=&b=", "Diff between any two snapshots of the same URL"],
14 + ["GET /api/v1/sources", "Monitored organizations (category, q)"],
15 + ["GET /api/v1/sources/{id}", "Source detail: sensors, entities, activity anomaly, discovered endpoints"],
16 + ["GET /api/v1/sensors/{id}", "Sensor detail: runs, snapshots, changes"],
17 + ["GET /api/v1/entities", "Entities (type, q)"],
18 + ["GET /api/v1/entities/{id}", "Entity detail: children, relations, sources, aliases, recent events"],
19 + ["GET /api/v1/entities/{id}/timeline", "Entity timeline (cursor pagination)"],
20 + ["GET /api/v1/domains/{domain}/timeline", "Domain: monitored URLs + events"],
21 + ["GET /api/v1/urls/history?url=", "URL history: snapshots, changes, events, removals"],
22 + ["GET /api/v1/search?q=", "Search events, entities, sources, URLs"],
23 + ["GET /api/v1/stats", "Platform counters"],
24 + ["GET /api/v1/trending?hours=24", "Trending entities"],
25 + ["GET /api/v1/explore", "Explore aggregates"],
26 + ["GET /api/v1/clusters", "Event clusters"],
27 + ["GET /api/v1/health/connectors", "Connector health"],
28 + ["GET/POST/PUT/DELETE /api/v1/watchlists", "Anonymous watchlists (header X-WebSensor-Owner)"],
29 + ["GET/POST/DELETE /api/v1/alerts", "Alert rules (header X-WebSensor-Owner)"],
30 + ["GET /api/v1/feed.rss", "RSS 2.0 of the latest events (same filters as /events)"],
31 + ["WSS /api/v1/live", "Real-time event stream"],
32 +];
33 +
34 +export default function ApiPage() {
35 + const base = SITE_URL;
36 + const wss = base.replace(/^http/, "ws");
37 + return (
38 + <>
39 + <PageHeader kicker="Machine-readable WebSensor" title="API" description="WebSensor practises what it monitors: stable URLs, JSON, RSS, JSON-LD and a WebSocket feed. Phase 1 is public and unauthenticated; rate limit 600 requests / minute / IP." />
40 + <div className="grid gap-4 lg:grid-cols-[1fr_380px]">
41 + <div className="flex flex-col gap-4">
42 + <Panel title="REST endpoints" dense>
43 + <Table head={["Endpoint", "Description"]}>
44 + {ENDPOINTS.map(([ep, desc]) => (
45 + <tr key={ep}>
46 + <Td mono className="whitespace-nowrap">{ep}</Td>
47 + <Td className="text-fg-muted">{desc}</Td>
48 + </tr>
49 + ))}
50 + </Table>
51 + </Panel>
52 + <Panel title="Examples">
53 + <div className="flex flex-col gap-3 text-[13px]">
54 + <p>Latest AI events with importance ≥ 70:</p>
55 + <Code>{`curl "${base}/api/v1/events?category=ai&importance_min=70&limit=20"`}</Code>
56 + <p>Silent pricing / terms changes only:</p>
57 + <Code>{`curl "${base}/api/v1/events?silent_change=true&event_type=pricing_change,terms_change"`}</Code>
58 + <p>OpenAI timeline as git-log style history:</p>
59 + <Code>{`curl "${base}/api/v1/entities/org_openai/timeline?limit=50"`}</Code>
60 + <p>Subscribe to an RSS reader:</p>
61 + <Code>{`${base}/api/v1/feed.rss?importance_min=60`}</Code>
62 + <p>Compare two snapshots of the same URL:</p>
63 + <Code>{`curl "${base}/api/v1/snapshots/compare?a=snap_…&b=snap_…"`}</Code>
64 + </div>
65 + </Panel>
66 + <Panel title="Event object">
67 + <Code lang="json">{`{
68 + "id": "evt_…", "slug": "openai-api-pricing-changed-…",
69 + "event_type": "pricing_change",
70 + "title": "OpenAI: price changed $10 / million tokens → $8 / million tokens",
71 + "summary": "…", "why_it_matters": "…",
72 + "importance": 91.2, "confidence": 98.1, "novelty": 87.4,
73 + "importance_components": { "severity": 82, "source": 92, "entity": 92, "novelty": 87, "magnitude": 40, "confirmation": 0, "userImpact": 85, "unusualness": 30 },
74 + "categories": ["ai", "technology"], "keywords": ["pricing"],
75 + "silent_change": true, "evidence_label": "OBSERVED",
76 + "url": "https://openai.com/api/pricing/",
77 + "published_at": null, "observed_from": "…", "detected_at": "…", "processed_at": "…",
78 + "detection_latency_ms": null, "processing_latency_ms": 412,
79 + "cluster_id": "clu_…", "change_id": "chg_…", "old_snapshot_id": "snap_…", "new_snapshot_id": "snap_…",
80 + "source": { "id": "openai", "name": "OpenAI", "domain": "openai.com", "tier": "S" },
81 + "sensor": { "id": "openai_pricing", "name": "pricing", "type": "HTML", "connector": "http" },
82 + "entities": [{ "id": "org_openai", "name": "OpenAI", "type": "organization", "role": "subject" }],
83 + "cluster_size": 3
84 +}`}</Code>
85 + </Panel>
86 + </div>
87 + <aside className="flex flex-col gap-4">
88 + <Panel title="WebSocket live stream">
89 + <div className="flex flex-col gap-3 text-[13px]">
90 + <Code>{`wscat -c ${wss}/api/v1/live
91 +> {"subscribe":["events:breaking","entity:org_openai"]}`}</Code>
92 + <p className="text-fg-muted">Channels:</p>
93 + <ul className="list-disc space-y-0.5 pl-5 font-mono text-[12px] text-fg-muted">
94 + <li>events:global (default)</li>
95 + <li>events:breaking (importance ≥ 80)</li>
96 + <li>events:silent</li>
97 + <li>events:ai · cyber · finance · health · government · science · products · infrastructure</li>
98 + <li>entity:{"{entity_id}"}</li>
99 + <li>source:{"{source_id}"}</li>
100 + <li>type:{"{event_type}"}</li>
101 + <li>watchlist:{"{watchlist_id}"}</li>
102 + </ul>
103 + <p className="text-fg-muted">Frames:</p>
104 + <Code lang="json">{`{"type":"hello","channels":["events:global"]}
105 +{"type":"event","channels":["events:global","events:ai"],
106 + "event":{"id":"evt_…","slug":"…","type":"model_release","title":"…",
107 + "importance":97,"confidence":95,"novelty":90,"silent":false,
108 + "evidence":"OBSERVED","source":{"id":"openai","name":"OpenAI","domain":"openai.com"},
109 + "sensor":{"id":"openai_news","name":"news feed","type":"RSS"},
110 + "entities":[{"id":"org_openai","name":"OpenAI","type":"organization"}],
111 + "categories":["ai"],"url":"https://…","clusterId":"clu_…",
112 + "detectedAt":"2026-09-08T12:15:22Z","publishedAt":"2026-09-08T12:14:55Z"}}
113 +{"type":"heartbeat","t":1757333722000}`}</Code>
114 + <p className="text-fg-muted">Send <code>{`{"ping":1}`}</code> for a <code>pong</code>. Heartbeats every 25 s keep proxies alive.</p>
115 + </div>
116 + </Panel>
117 + <Panel title="Conventions">
118 + <ul className="list-disc space-y-1 pl-5 text-[12.5px] text-fg-muted">
119 + <li>All timestamps are UTC ISO-8601.</li>
120 + <li>Cursor pagination: pass <code>nextCursor</code> back as <code>cursor</code>.</li>
121 + <li>Scores are 0–100. Importance and confidence are independent.</li>
122 + <li>Every event traces to change → snapshots → sensor → source; snapshots are immutable.</li>
123 + <li>Labels OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED are always exposed.</li>
124 + <li>Planned: API keys, customer webhooks, MCP server.</li>
125 + </ul>
126 + </Panel>
127 + </aside>
128 + </div>
129 + </>
130 + );
131 +}
added apps/web/src/app/bot/page.tsx +36 −0
@@ -0,0 +1,36 @@
1 +import type { Metadata } from "next";
2 +import { PageHeader, Panel } from "@/components/ui";
3 +
4 +export const metadata: Metadata = { title: "WebSensorBot", description: "About the WebSensor crawler: what it fetches, how often, and how to contact us." };
5 +
6 +export default function BotPage() {
7 + return (
8 + <>
9 + <PageHeader kicker="Crawler policy" title="WebSensorBot" description="WebSensor monitors official public endpoints of well-known organizations and detects meaningful changes. This page describes how our fetcher behaves." />
10 + <div className="grid gap-4 lg:grid-cols-2">
11 + <Panel title="Identification">
12 + <p className="text-[13px]">User agent:</p>
13 + <pre className="mt-1 rounded-md border border-line bg-panel-2 p-2 font-mono text-[12px]">WebSensorBot/0.1 (+https://www.websensor.io/bot; contact@websensor.io)</pre>
14 + <p className="mt-3 text-[13px] text-fg-muted">Requests originate from a small, fixed set of addresses. Contact: <a href="mailto:contact@websensor.io" className="text-info hover:underline">contact@websensor.io</a>.</p>
15 + </Panel>
16 + <Panel title="What we fetch">
17 + <ul className="list-disc space-y-1 pl-5 text-[13px] text-fg-muted">
18 + <li>Official RSS/Atom feeds, sitemaps, status-page APIs, GitHub release feeds and public JSON APIs first.</li>
19 + <li>A small number of high-value HTML pages (pricing, changelog, security, documentation) when no structured alternative exists.</li>
20 + <li>No login, no CAPTCHAs, no paywalls, no private infrastructure. Robots directives and rate limits are honoured.</li>
21 + </ul>
22 + </Panel>
23 + <Panel title="How often">
24 + <ul className="list-disc space-y-1 pl-5 text-[13px] text-fg-muted">
25 + <li>Conditional requests (<code>If-None-Match</code> / <code>If-Modified-Since</code>) whenever the server supports them — most checks are 304s.</li>
26 + <li>Adaptive polling: from about once a minute for critical status feeds to a few times a day for slow-moving pages; at most 2 concurrent connections per host.</li>
27 + <li>Back-off on errors and on HTTP 429 (Retry-After honoured).</li>
28 + </ul>
29 + </Panel>
30 + <Panel title="Opting out">
31 + <p className="text-[13px] text-fg-muted">Add a <code>Disallow</code> rule for <code>WebSensorBot</code> in your <code>robots.txt</code>, or email us and we will disable the sensors for your domain.</p>
32 + </Panel>
33 + </div>
34 + </>
35 + );
36 +}
added apps/web/src/app/breaking/page.tsx +17 −0
@@ -0,0 +1,17 @@
1 +import type { Metadata } from "next";
2 +import { LiveFeed } from "@/components/live-feed";
3 +import { PageHeader } from "@/components/ui";
4 +import { api } from "@/lib/api";
5 +
6 +export const dynamic = "force-dynamic";
7 +export const metadata: Metadata = { title: "Breaking", description: "Events with importance ≥ 80 detected in the last 48 hours." };
8 +
9 +export default async function BreakingPage() {
10 + const events = await api.breaking(60);
11 + return (
12 + <>
13 + <PageHeader kicker="Importance ≥ 80 · last 48 h" title="Breaking" description="Highest-importance events across every category, ranked by score. New breaking events stream in live." />
14 + <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed="breaking" title="BREAKING" />
15 + </>
16 + );
17 +}
added apps/web/src/app/category/[channel]/page.tsx +29 −0
@@ -0,0 +1,29 @@
1 +import type { Metadata } from "next";
2 +import { notFound } from "next/navigation";
3 +import { LiveFeed } from "@/components/live-feed";
4 +import { PageHeader } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { CHANNELS } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +const ALLOWED = new Set(["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure", "cloud", "developer", "consumer-tech", "semiconductors", "pharma", "statistics", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards", "technology"]);
11 +
12 +export async function generateMetadata({ params }: { params: Promise<{ channel: string }> }): Promise<Metadata> {
13 + const { channel } = await params;
14 + const label = CHANNELS.find((c) => c.key === channel)?.label ?? channel;
15 + return { title: `${label} events`, description: `Live ${label} events detected by WebSensor.`, alternates: { canonical: `/category/${channel}` } };
16 +}
17 +
18 +export default async function CategoryPage({ params }: { params: Promise<{ channel: string }> }) {
19 + const { channel } = await params;
20 + if (!ALLOWED.has(channel)) notFound();
21 + const chan = CHANNELS.find((c) => c.key === channel);
22 + const events = await api.events({ category: channel, limit: 60 });
23 + return (
24 + <>
25 + <PageHeader kicker="Category" title={chan?.label ?? channel} description={`Meaningful changes from sources categorized as “${channel}”.`} />
26 + <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed={chan ? chan.key : undefined} title={(chan?.label ?? channel).toUpperCase()} extraQuery={chan ? undefined : { category: channel }} showTabs={false} />
27 + </>
28 + );
29 +}
added apps/web/src/app/company/[id]/page.tsx +111 −0
@@ -0,0 +1,111 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import { Timeline } from "@/components/timeline";
5 +import { Bar, Chip, Empty, ExtLink, PageHeader, Panel, Stat } from "@/components/ui";
6 +import { WatchButton } from "@/components/watch-button";
7 +import { api } from "@/lib/api";
8 +import { fmtInt, fmtScore, relTime, typeLabel } from "@/lib/format";
9 +
10 +export const dynamic = "force-dynamic";
11 +
12 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
13 + const { id } = await params;
14 + const d = await api.entity(id);
15 + if (!d) return { title: "Entity not found" };
16 + return { title: `${d.entity.name} — timeline`, description: d.entity.description ?? `Every meaningful change detected for ${d.entity.name}: pricing, products, documentation, incidents, silent changes.`, alternates: { canonical: `/company/${d.entity.id}` } };
17 +}
18 +
19 +export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) {
20 + const { id } = await params;
21 + const d = await api.entity(id);
22 + if (!d) notFound();
23 + const e = d.entity;
24 + const maxType = Math.max(1, ...d.by_type.map((t) => t.n));
25 + return (
26 + <>
27 + <PageHeader
28 + kicker={<span className="flex items-center gap-2"><Chip>{e.type}</Chip>{e.domain && <Link href={`/domain/${e.domain}`} className="font-mono hover:underline">{e.domain}</Link>}</span>}
29 + title={e.name}
30 + description={e.description}
31 + actions={
32 + <>
33 + <WatchButton kind="entity" value={e.id} />
34 + <Link href={`/timeline/${e.id}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Full timeline →</Link>
35 + {e.homepage && <ExtLink href={e.homepage} className="text-[12px]">Website ↗</ExtLink>}
36 + </>
37 + }
38 + />
39 + <div className="panel mb-4 grid grid-cols-2 divide-x divide-line sm:grid-cols-4">
40 + <Stat label="Events" value={fmtInt(e.event_count)} hint={e.last_event_at ? `last ${relTime(e.last_event_at)}` : undefined} />
41 + <Stat label="Importance" value={fmtScore(e.importance)} hint="entity weight" />
42 + <Stat label="Sources" value={fmtInt(d.sources.length)} />
43 + <Stat label="Silent" value={fmtInt(d.recent.filter((x) => x.silent_change).length)} hint="in recent 30" />
44 + </div>
45 + <div className="grid gap-4 lg:grid-cols-[1fr_320px]">
46 + <Panel title="Timeline" dense action={<Link href={`/timeline/${e.id}`} className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}>
47 + <Timeline events={d.recent} />
48 + </Panel>
49 + <aside className="flex flex-col gap-4">
50 + {d.children.length > 0 && (
51 + <Panel title="Products & children" dense>
52 + <ul className="divide-y divide-line">
53 + {d.children.map((c) => (
54 + <li key={c.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
55 + <Link href={`/company/${c.id}`} className="hover:underline">{c.name}</Link>
56 + <span className="font-mono text-[11px] text-fg-subtle">{c.type} · {c.event_count}</span>
57 + </li>
58 + ))}
59 + </ul>
60 + </Panel>
61 + )}
62 + <Panel title="Event types">
63 + {d.by_type.length ? (
64 + <ul className="space-y-1.5">
65 + {d.by_type.slice(0, 12).map((t) => (
66 + <li key={t.event_type} className="grid grid-cols-[8rem_1fr_2.5rem] items-center gap-2 text-[12px]">
67 + <span className="truncate">{typeLabel(t.event_type)}</span>
68 + <Bar value={t.n} max={maxType} tone="info" />
69 + <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span>
70 + </li>
71 + ))}
72 + </ul>
73 + ) : (
74 + <Empty>No events yet.</Empty>
75 + )}
76 + </Panel>
77 + <Panel title="Sources" dense>
78 + {d.sources.length ? (
79 + <ul className="divide-y divide-line">
80 + {d.sources.map((s) => (
81 + <li key={s.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
82 + <Link href={`/source/${s.id}`} className="hover:underline">{s.name}</Link>
83 + <span className="font-mono text-[11px] text-fg-subtle">{s.domain}</span>
84 + </li>
85 + ))}
86 + </ul>
87 + ) : (
88 + <Empty>No monitored source linked.</Empty>
89 + )}
90 + </Panel>
91 + {d.relations.length > 0 && (
92 + <Panel title="Knowledge graph" dense>
93 + <ul className="divide-y divide-line">
94 + {d.relations.map((r, i) => (
95 + <li key={i} className="px-3 py-1.5 text-[12.5px]">
96 + <Link href={`/company/${r.from_id}`} className="hover:underline">{r.from_name}</Link> <span className="font-mono text-[11px] text-fg-subtle">→ {r.relation} →</span> <Link href={`/company/${r.to_id}`} className="hover:underline">{r.to_name}</Link>
97 + </li>
98 + ))}
99 + </ul>
100 + </Panel>
101 + )}
102 + {d.aliases.length > 0 && (
103 + <Panel title="Aliases">
104 + <div className="flex flex-wrap gap-1">{d.aliases.map((a) => <Chip key={a}>{a}</Chip>)}</div>
105 + </Panel>
106 + )}
107 + </aside>
108 + </div>
109 + </>
110 + );
111 +}
added apps/web/src/app/compare/page.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { DiffViewer } from "@/components/diff-viewer";
4 +import { Empty, PageHeader, Panel } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { utcDateTime } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +export const metadata: Metadata = { title: "Compare snapshots", robots: { index: false } };
10 +
11 +export default async function ComparePage({ searchParams }: { searchParams: Promise<{ a?: string; b?: string }> }) {
12 + const { a, b } = await searchParams;
13 + const d = a && b ? await api.compare(a, b) : null;
14 + return (
15 + <>
16 + <PageHeader kicker="Snapshot comparison" title={d ? <span className="font-mono text-base">{utcDateTime(d.a.captured_at)} → {utcDateTime(d.b.captured_at)}</span> : "Compare"} description={d?.a.url ? <Link href={`/url?u=${encodeURIComponent(d.a.url)}`} className="font-mono text-[12px] hover:underline">{d.a.url}</Link> : "Provide two snapshot ids (?a=&b=)."} />
17 + {d ? (
18 + <DiffViewer unified={d.diff.unified} summary={{ kind: "text", added: d.diff.added, removed: d.diff.removed, modified: d.diff.modified, stats: d.diff.stats }} defaultTab="split" />
19 + ) : (
20 + <Panel dense><Empty>Snapshots not found.</Empty></Panel>
21 + )}
22 + </>
23 + );
24 +}
added apps/web/src/app/domain/[domain]/page.tsx +51 −0
@@ -0,0 +1,51 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { Timeline } from "@/components/timeline";
4 +import { Chip, Empty, PageHeader, Panel, Table, Td } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { relTime } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +export async function generateMetadata({ params }: { params: Promise<{ domain: string }> }): Promise<Metadata> {
11 + const { domain } = await params;
12 + return { title: `${domain} — domain timeline`, description: `Monitored URLs and detected changes on ${domain}.`, alternates: { canonical: `/domain/${domain}` } };
13 +}
14 +
15 +export default async function DomainPage({ params, searchParams }: { params: Promise<{ domain: string }>; searchParams: Promise<{ cursor?: string }> }) {
16 + const { domain } = await params;
17 + const { cursor } = await searchParams;
18 + const d = await api.domain(domain, cursor);
19 + return (
20 + <>
21 + <PageHeader kicker="Domain" title={<span className="font-mono">{domain}</span>} description="Every monitored URL on this domain with its history, plus the event timeline." actions={d?.source_id ? <Link href={`/source/${d.source_id}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Source page →</Link> : undefined} />
22 + <div className="grid gap-4 lg:grid-cols-[1fr_1fr]">
23 + <Panel title={`URLs · ${d?.urls.length ?? 0}`} dense>
24 + {d?.urls.length ? (
25 + <Table head={["URL", "Status", "Snapshots", "Changes", "Last seen"]}>
26 + {d.urls.map((u) => (
27 + <tr key={u.url}>
28 + <Td><Link href={`/url?u=${encodeURIComponent(u.url)}`} className="block max-w-[26rem] truncate font-mono text-[11.5px] hover:underline">{u.url.replace(/^https?:\/\//, "")}</Link></Td>
29 + <Td><Chip tone={u.status === "active" ? "ok" : u.status === "removed" ? "danger" : "warn"}>{u.status}</Chip></Td>
30 + <Td mono>{u.snapshot_count}</Td>
31 + <Td mono>{u.change_count}</Td>
32 + <Td mono className="text-fg-subtle">{relTime(u.last_seen_at)}</Td>
33 + </tr>
34 + ))}
35 + </Table>
36 + ) : (
37 + <Empty>No URLs monitored on this domain yet.</Empty>
38 + )}
39 + </Panel>
40 + <Panel title="Timeline" dense>
41 + <Timeline events={d?.events ?? []} />
42 + {d?.nextCursor && (
43 + <div className="flex justify-end px-3 py-2 text-[12px]">
44 + <Link href={`/domain/${domain}?cursor=${encodeURIComponent(d.nextCursor)}`} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 hover:border-line-strong">Older →</Link>
45 + </div>
46 + )}
47 + </Panel>
48 + </div>
49 + </>
50 + );
51 +}
added apps/web/src/app/entities/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { ENTITY_TYPES } from "@websensor/core/client";
4 +import { Chip, Empty, PageHeader, Panel, Table, Td } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { fmtInt, fmtScore, relTime } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +export const metadata: Metadata = { title: "Entities", description: "Organizations, products, models, APIs and other entities that events resolve to." };
10 +
11 +export default async function EntitiesPage({ searchParams }: { searchParams: Promise<{ type?: string; q?: string }> }) {
12 + const sp = await searchParams;
13 + const { items } = await api.entities({ type: sp.type, q: sp.q, limit: 400 });
14 + const types = new Set(items.map((e) => e.type));
15 + return (
16 + <>
17 + <PageHeader kicker={`${fmtInt(items.length)} entities`} title="Entities" description="Everything important resolves to an entity. Each entity has a timeline — the equivalent of git history for its public Web presence." />
18 + <form className="mb-3 flex flex-wrap items-center gap-2" action="/entities">
19 + <input name="q" defaultValue={sp.q ?? ""} placeholder="Search entities…" className="h-8 w-64 rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle" />
20 + {sp.type && <input type="hidden" name="type" value={sp.type} />}
21 + <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px]">Search</button>
22 + </form>
23 + <div className="mb-3 flex flex-wrap gap-1">
24 + <Chip href="/entities" tone={!sp.type ? "signal" : "default"}>all</Chip>
25 + {ENTITY_TYPES.filter((t) => types.has(t) || t === sp.type).map((t) => (
26 + <Chip key={t} href={`/entities?type=${t}`} tone={sp.type === t ? "signal" : "default"}>{t}</Chip>
27 + ))}
28 + </div>
29 + <Panel dense>
30 + {items.length === 0 ? (
31 + <Empty>No entities match.</Empty>
32 + ) : (
33 + <Table head={["Entity", "Type", "Domain", "Importance", "Events", "24 h", "Last event"]}>
34 + {items.map((e) => (
35 + <tr key={e.id} className="hover:bg-panel-2/60">
36 + <Td><Link href={`/company/${e.id}`} className="font-medium hover:underline">{e.name}</Link></Td>
37 + <Td><Chip>{e.type}</Chip></Td>
38 + <Td mono className="text-fg-subtle">{e.domain ?? "—"}</Td>
39 + <Td mono>{fmtScore(e.importance)}</Td>
40 + <Td mono>{fmtInt(e.event_count)}</Td>
41 + <Td mono className={e.events_24h ? "text-signal" : "text-fg-subtle"}>{e.events_24h ?? 0}</Td>
42 + <Td mono className="text-fg-subtle">{e.last_event_at ? relTime(e.last_event_at) : "—"}</Td>
43 + </tr>
44 + ))}
45 + </Table>
46 + )}
47 + </Panel>
48 + </>
49 + );
50 +}
added apps/web/src/app/error.tsx +14 −0
@@ -0,0 +1,14 @@
1 +"use client";
2 +
3 +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4 + return (
5 + <div className="mx-auto max-w-lg py-20 text-center">
6 + <div className="label mb-2">Error</div>
7 + <h1 className="text-xl font-semibold">Something went wrong while rendering</h1>
8 + <p className="mt-2 font-mono text-[12px] text-fg-subtle">{error.digest ?? error.message}</p>
9 + <button type="button" onClick={reset} className="mt-5 rounded-md border border-line bg-panel px-3 py-1.5 text-[13px] hover:border-line-strong">
10 + Try again
11 + </button>
12 + </div>
13 + );
14 +}
added apps/web/src/app/event/[slug]/page.tsx +254 −0
@@ -0,0 +1,254 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import { DiffViewer } from "@/components/diff-viewer";
5 +import { EventRow } from "@/components/event-row";
6 +import { Chip, Empty, EvidenceTag, ExtLink, Gauge, HealthPill, PageHeader, Panel, Score, SilentBadge, Table, Td, TypeChip } from "@/components/ui";
7 +import { ShareButton, WatchButton } from "@/components/watch-button";
8 +import { api, SITE_URL } from "@/lib/api";
9 +import { fmtBytes, fmtMs, fmtPct, fmtScore, relTime, shortHash, typeLabel, utcDateTime } from "@/lib/format";
10 +
11 +export const dynamic = "force-dynamic";
12 +
13 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
14 + const { slug } = await params;
15 + const d = await api.event(slug);
16 + if (!d) return { title: "Event not found" };
17 + const e = d.event;
18 + const desc = e.summary.slice(0, 200);
19 + return {
20 + title: e.title,
21 + description: desc,
22 + alternates: { canonical: `/event/${e.slug}` },
23 + openGraph: { type: "article", title: e.title, description: desc, url: `${SITE_URL}/event/${e.slug}`, publishedTime: e.detected_at, tags: [e.event_type, ...e.categories] },
24 + twitter: { card: "summary_large_image", title: e.title, description: desc },
25 + };
26 +}
27 +
28 +const COMPONENT_WEIGHTS: [string, string, number][] = [
29 + ["severity", "Intrinsic event severity", 25],
30 + ["source", "Source importance", 20],
31 + ["entity", "Entity importance", 15],
32 + ["novelty", "Novelty", 15],
33 + ["magnitude", "Magnitude of change", 10],
34 + ["confirmation", "Cross-source confirmation", 5],
35 + ["userImpact", "User impact", 5],
36 + ["unusualness", "Unusualness", 5],
37 +];
38 +
39 +export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) {
40 + const { slug } = await params;
41 + const d = await api.event(slug);
42 + if (!d) notFound();
43 + const e = d.event;
44 + const change = e.change_id ? await api.change(e.change_id) : null;
45 + const interp = (e.interpretation ?? {}) as Record<string, unknown>;
46 + const observed = typeof interp.observed === "string" ? interp.observed : null;
47 + const inferred = typeof interp.inferred === "string" && interp.inferred.trim() ? interp.inferred : null;
48 + const who = typeof interp.who_it_affects === "string" ? interp.who_it_affects : null;
49 + const model = typeof interp.model === "string" ? interp.model : "heuristics-v1";
50 + const oldSnap = d.snapshots.find((s) => s.id === e.old_snapshot_id);
51 + const newSnap = d.snapshots.find((s) => s.id === e.new_snapshot_id);
52 + const subject = e.entities.find((x) => x.role === "subject") ?? e.entities[0];
53 + const jsonLd = {
54 + "@context": "https://schema.org",
55 + "@type": "NewsArticle",
56 + headline: e.title,
57 + description: e.summary,
58 + datePublished: e.detected_at,
59 + dateModified: e.processed_at ?? e.detected_at,
60 + url: `${SITE_URL}/event/${e.slug}`,
61 + mainEntityOfPage: `${SITE_URL}/event/${e.slug}`,
62 + author: { "@type": "Organization", name: "WebSensor", url: SITE_URL },
63 + publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL },
64 + about: e.entities.map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/company/${x.id}` })),
65 + isBasedOn: e.url,
66 + keywords: [e.event_type, ...e.categories, ...e.keywords].join(", "),
67 + };
68 + return (
69 + <>
70 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
71 + <PageHeader
72 + kicker={
73 + <span className="flex flex-wrap items-center gap-2">
74 + <Link href={`/source/${e.source?.id ?? e.source_id}`} className="font-mono uppercase tracking-wide text-fg-muted hover:text-fg">{e.source?.name}</Link>
75 + <TypeChip type={e.event_type} />
76 + {e.silent_change && <SilentBadge />}
77 + <EvidenceTag label={e.evidence_label} />
78 + {e.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}
79 + </span>
80 + }
81 + title={e.title}
82 + actions={
83 + <>
84 + <ShareButton path={`/event/${e.slug}`} />
85 + {subject && <WatchButton kind="entity" value={subject.id} label={`Watch ${subject.name}`} />}
86 + <Link href="/alerts" className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Alert</Link>
87 + </>
88 + }
89 + />
90 + <div className="grid gap-4 lg:grid-cols-[1fr_340px]">
91 + <div className="flex flex-col gap-4">
92 + <Panel>
93 + <p className="text-[15px] leading-relaxed">{e.summary}</p>
94 + {e.why_it_matters && (
95 + <div className="mt-4">
96 + <div className="label mb-1">Why it matters</div>
97 + <p className="text-[13.5px] leading-relaxed text-fg-muted">{e.why_it_matters}</p>
98 + </div>
99 + )}
100 + {who && (
101 + <div className="mt-3">
102 + <div className="label mb-1">Who it affects</div>
103 + <p className="text-[13.5px] text-fg-muted">{who}</p>
104 + </div>
105 + )}
106 + <div className="mt-4 grid gap-3 sm:grid-cols-2">
107 + <div className="rounded-md border border-line bg-panel-2 p-3">
108 + <div className="label mb-1 !text-ok">Observed</div>
109 + <p className="text-[13px]">{observed ?? "Facts are limited to the diff below: the monitored endpoint changed between the two preserved snapshots."}</p>
110 + </div>
111 + <div className="rounded-md border border-line bg-panel-2 p-3">
112 + <div className="label mb-1 !text-info">Inferred</div>
113 + <p className="text-[13px] text-fg-muted">{inferred ?? "No inference beyond the observed change."}</p>
114 + <p className="mt-1 font-mono text-[11px] text-fg-subtle">confidence {fmtScore(e.confidence)}% · {model}</p>
115 + </div>
116 + </div>
117 + </Panel>
118 +
119 + <div>
120 + <div className="mb-2 flex items-center justify-between">
121 + <h2 className="label">Diff · {change?.change.kind ?? d.change?.kind ?? "—"}</h2>
122 + {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="text-[11px] text-fg-subtle hover:text-fg">open in compare →</Link>}
123 + </div>
124 + {change ? <DiffViewer unified={change.unified} summary={change.change.diff} defaultTab={change.change.kind === "text" ? "unified" : "semantic"} /> : <Panel dense><Empty>No diff is attached to this event{e.event_type === "page_removed" ? " — the page disappeared; its last snapshot is preserved below." : "."}</Empty></Panel>}
125 + </div>
126 +
127 + <Panel title="Evidence" dense>
128 + <div className="px-3 py-2 text-[13px]">
129 + <div className="label mb-1">Monitored URL</div>
130 + <ExtLink href={e.url} className="break-all font-mono text-[12.5px]">{e.url}</ExtLink>
131 + <span className="ml-2 text-[11px] text-fg-subtle">· <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="hover:underline">URL history</Link> · <Link href={`/sensor/${e.sensor_id}`} className="hover:underline">sensor {e.sensor?.name}</Link></span>
132 + </div>
133 + <Table head={["Snapshot", "Captured", "HTTP", "Size", "Canonical hash", "Raw hash", ""]}>
134 + {[["Before", oldSnap], ["After", newSnap]].map(([label, s]) => {
135 + const snap = s as typeof oldSnap;
136 + return (
137 + <tr key={String(label)}>
138 + <Td className="font-medium">{String(label)}</Td>
139 + <Td mono className="text-fg-subtle">{snap ? utcDateTime(snap.captured_at) : "—"}</Td>
140 + <Td mono>{snap?.http_status ?? "—"}</Td>
141 + <Td mono>{snap ? fmtBytes(snap.content_length) : "—"}</Td>
142 + <Td mono className="text-fg-subtle">{shortHash(snap?.canonical_hash)}</Td>
143 + <Td mono className="text-fg-subtle">{shortHash(snap?.content_hash)}</Td>
144 + <Td>{snap ? <a href={`/api/v1/snapshots/${snap.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a> : ""}</Td>
145 + </tr>
146 + );
147 + })}
148 + </Table>
149 + <div className="px-3 py-2 font-mono text-[11px] text-fg-subtle">
150 + event {e.id} · change {e.change_id ?? "—"} · {e.processing_version ?? ""} · trace: event → change → snapshots → sensor {e.sensor_id} → source {e.source_id}
151 + </div>
152 + </Panel>
153 +
154 + <Panel title="Related events" dense>
155 + {d.related.length ? d.related.map((r) => <EventRow key={r.id} ev={r} showDate />) : <Empty>No related events yet.</Empty>}
156 + </Panel>
157 + </div>
158 +
159 + <aside className="flex flex-col gap-4">
160 + <Panel>
161 + <div className="mb-3 flex items-center gap-3">
162 + <Score value={e.importance} size="lg" />
163 + <div>
164 + <div className="label">Importance</div>
165 + <div className="text-[12px] text-fg-muted">{e.importance >= 90 ? "critical" : e.importance >= 75 ? "major" : e.importance >= 50 ? "notable" : "minor"}</div>
166 + </div>
167 + </div>
168 + <div className="flex flex-col gap-3">
169 + <Gauge label="Confidence" value={e.confidence} tone="info" />
170 + <Gauge label="Novelty" value={e.novelty} tone="signal" />
171 + </div>
172 + </Panel>
173 + <Panel title="Timing">
174 + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">
175 + <dt className="text-fg-subtle">Published</dt><dd className="text-right font-mono tabular">{e.published_at ? utcDateTime(e.published_at) : "—"}</dd>
176 + <dt className="text-fg-subtle">Observed from</dt><dd className="text-right font-mono tabular">{e.observed_from ? utcDateTime(e.observed_from) : "—"}</dd>
177 + <dt className="text-fg-subtle">Detected</dt><dd className="text-right font-mono tabular">{utcDateTime(e.detected_at)}</dd>
178 + <dt className="text-fg-subtle">Processed</dt><dd className="text-right font-mono tabular">{e.processed_at ? utcDateTime(e.processed_at) : "—"}</dd>
179 + <dt className="text-fg-subtle">To feed</dt><dd className="text-right font-mono tabular">{e.published_to_feed_at ? utcDateTime(e.published_to_feed_at) : "—"}</dd>
180 + <dt className="text-fg-subtle">Detection latency</dt><dd className="text-right font-mono tabular">{fmtMs(e.detection_latency_ms)}</dd>
181 + <dt className="text-fg-subtle">Processing latency</dt><dd className="text-right font-mono tabular">{fmtMs(e.processing_latency_ms)}</dd>
182 + </dl>
183 + <p className="mt-2 text-[11px] text-fg-subtle">{relTime(e.detected_at)} · all times UTC</p>
184 + </Panel>
185 + <Panel title="Entities" dense>
186 + {e.entities.length ? (
187 + <ul className="divide-y divide-line">
188 + {e.entities.map((x) => (
189 + <li key={x.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
190 + <Link href={`/company/${x.id}`} className="hover:underline">{x.name}</Link>
191 + <span className="font-mono text-[11px] text-fg-subtle">{x.type}{x.role === "subject" ? " · subject" : ""}</span>
192 + </li>
193 + ))}
194 + </ul>
195 + ) : (
196 + <Empty>No entity resolved.</Empty>
197 + )}
198 + </Panel>
199 + <Panel title="Importance components" dense>
200 + <Table head={["Component", "Weight", "Score"]}>
201 + {COMPONENT_WEIGHTS.map(([k, label, w]) => (
202 + <tr key={k}>
203 + <Td>{label}</Td>
204 + <Td mono className="text-fg-subtle">{w}%</Td>
205 + <Td mono>{e.importance_components?.[k] !== undefined ? fmtScore(e.importance_components[k]) : "—"}</Td>
206 + </tr>
207 + ))}
208 + </Table>
209 + </Panel>
210 + {d.cluster && (
211 + <Panel title="Event cluster">
212 + <div id="cluster" className="text-[13px]">
213 + <div className="font-medium">{d.cluster.title}</div>
214 + <div className="mt-1 text-[11.5px] text-fg-subtle">{d.cluster.event_count} related observation{d.cluster.event_count === 1 ? "" : "s"} · max importance {fmtScore(d.cluster.max_importance)} · {relTime(d.cluster.first_at)} → {relTime(d.cluster.last_at)}</div>
215 + {d.cluster.entity_ids.length > 0 && <div className="mt-2 flex flex-wrap gap-1">{d.cluster.entity_ids.slice(0, 8).map((id) => <Chip key={id} href={`/company/${id}`}>{id.replace(/^(org|prd)_/, "")}</Chip>)}</div>}
216 + </div>
217 + </Panel>
218 + )}
219 + <Panel title="Source reliability">
220 + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">
221 + <dt className="text-fg-subtle">Sensor health</dt><dd className="text-right"><HealthPill health={d.sensor_reliability?.health} /></dd>
222 + <dt className="text-fg-subtle">Connector success</dt><dd className="text-right font-mono tabular">{fmtPct(d.sensor_reliability?.success_rate ?? null)}</dd>
223 + <dt className="text-fg-subtle">Latency</dt><dd className="text-right font-mono tabular">{fmtMs(d.sensor_reliability?.avg_latency_ms)}</dd>
224 + <dt className="text-fg-subtle">Runs</dt><dd className="text-right font-mono tabular">{d.sensor_reliability?.total_runs ?? "—"}</dd>
225 + <dt className="text-fg-subtle">Raw / meaningful</dt><dd className="text-right font-mono tabular">{d.sensor_reliability?.raw_changes ?? "—"} / {d.sensor_reliability?.meaningful_changes ?? "—"}</dd>
226 + </dl>
227 + <p className="mt-2 text-[11px] text-fg-subtle">Sensor type {e.sensor?.type} via {e.sensor?.connector} connector · tier {e.sensor?.tier}</p>
228 + </Panel>
229 + <Panel title="Interpretation versions" dense>
230 + {d.interpretations.length ? (
231 + <ul className="divide-y divide-line">
232 + {d.interpretations.map((v) => (
233 + <li key={v.version} className="flex items-center justify-between px-3 py-1.5 text-[12.5px]">
234 + <span>v{v.version} · <span className="font-mono">{v.model}</span></span>
235 + <span className="font-mono text-[11px] text-fg-subtle">{utcDateTime(v.created_at)}</span>
236 + </li>
237 + ))}
238 + </ul>
239 + ) : (
240 + <Empty>—</Empty>
241 + )}
242 + <p className="px-3 py-2 text-[11px] text-fg-subtle">Raw evidence is immutable; interpretations may be reprocessed and are versioned.</p>
243 + </Panel>
244 + {e.keywords.length > 0 && (
245 + <Panel title="Keywords">
246 + <div className="flex flex-wrap gap-1">{e.keywords.map((k) => <Chip key={k} href={`/search?q=${encodeURIComponent(k)}`}>{k}</Chip>)}</div>
247 + </Panel>
248 + )}
249 + <p className="text-[11px] text-fg-subtle">{typeLabel(e.event_type)} · detected by WebSensor · AI-generated summaries never replace the original evidence above.</p>
250 + </aside>
251 + </div>
252 + </>
253 + );
254 +}
added apps/web/src/app/explore/page.tsx +112 −0
@@ -0,0 +1,112 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { EventRow } from "@/components/event-row";
4 +import { Bar, Empty, PageHeader, Panel, Score } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { fmtScore, relTime, typeLabel } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +export const metadata: Metadata = { title: "Explore", description: "Most active sources, biggest changes, silent changes, unusual activity and event clusters." };
10 +
11 +export default async function ExplorePage() {
12 + const x = await api.explore();
13 + const maxType = Math.max(1, ...(x?.by_type ?? []).map((t) => t.n));
14 + const maxCat = Math.max(1, ...(x?.by_category ?? []).map((t) => t.n));
15 + return (
16 + <>
17 + <PageHeader kicker="Discover" title="Explore" description="Derived views over the event store: who is changing the most, what mattered most, what changed silently, and where activity is abnormal." />
18 + <div className="grid gap-4 lg:grid-cols-3">
19 + <Panel title="Most active sources · 24 h" dense>
20 + {x?.most_active_sources?.length ? (
21 + <ul className="divide-y divide-line">
22 + {x.most_active_sources.map((s) => (
23 + <li key={s.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
24 + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link>
25 + <span className="font-mono text-[12px] text-fg-subtle tabular">{s.events_24h} ev</span>
26 + <Score value={s.max_importance} size="sm" />
27 + </li>
28 + ))}
29 + </ul>
30 + ) : (
31 + <Empty />
32 + )}
33 + </Panel>
34 + <Panel title="Unusual activity" dense>
35 + {x?.unusual_activity?.length ? (
36 + <ul className="divide-y divide-line">
37 + {x.unusual_activity.map((s) => (
38 + <li key={s.id} className="px-3 py-1.5 text-[13px]">
39 + <div className="flex items-center gap-2">
40 + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link>
41 + <span className={`font-mono text-[12px] font-semibold tabular ${s.activity_score >= 70 ? "text-hot" : s.activity_score >= 45 ? "text-high" : "text-fg-muted"}`}>{fmtScore(s.activity_score)}</span>
42 + </div>
43 + <div className="text-[11px] text-fg-subtle">{s.changes_2h} changes in 2 h · baseline {s.baseline_per_day}/day</div>
44 + <div className="mt-1"><Bar value={s.activity_score} tone={s.activity_score >= 70 ? "hot" : s.activity_score >= 45 ? "high" : "signal"} /></div>
45 + </li>
46 + ))}
47 + </ul>
48 + ) : (
49 + <Empty>Activity anomalies compare the last 2 h of raw changes with a 14-day baseline.</Empty>
50 + )}
51 + </Panel>
52 + <Panel title="Event clusters · 48 h" dense>
53 + {x?.clusters?.length ? (
54 + <ul className="divide-y divide-line">
55 + {x.clusters.map((c) => (
56 + <li key={c.id} className="flex items-start gap-2 px-3 py-1.5 text-[13px]">
57 + <Score value={c.max_importance} size="sm" />
58 + <div className="min-w-0">
59 + <div className="line-clamp-2 font-medium">{c.title}</div>
60 + <div className="text-[11px] text-fg-subtle">{c.event_count} observations · {c.source?.name ?? ""} · {relTime(c.last_at)}</div>
61 + </div>
62 + </li>
63 + ))}
64 + </ul>
65 + ) : (
66 + <Empty>No multi-observation clusters yet.</Empty>
67 + )}
68 + </Panel>
69 + </div>
70 + <div className="mt-4 grid gap-4 lg:grid-cols-2">
71 + <Panel title="Biggest changes · 48 h" dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">breaking →</Link>}>
72 + {x?.biggest_changes?.length ? x.biggest_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty />}
73 + </Panel>
74 + <Panel title="Silent changes" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all silent →</Link>}>
75 + {x?.silent_changes?.length ? x.silent_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No silent changes yet.</Empty>}
76 + </Panel>
77 + </div>
78 + <div className="mt-4 grid gap-4 lg:grid-cols-2">
79 + <Panel title="Events by type · 7 d">
80 + {x?.by_type?.length ? (
81 + <ul className="space-y-1.5">
82 + {x.by_type.slice(0, 16).map((t) => (
83 + <li key={t.event_type} className="grid grid-cols-[10rem_1fr_3rem] items-center gap-2 text-[12.5px]">
84 + <span className="truncate">{typeLabel(t.event_type)}</span>
85 + <Bar value={t.n} max={maxType} tone="info" />
86 + <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span>
87 + </li>
88 + ))}
89 + </ul>
90 + ) : (
91 + <Empty />
92 + )}
93 + </Panel>
94 + <Panel title="Events by category · 7 d">
95 + {x?.by_category?.length ? (
96 + <ul className="space-y-1.5">
97 + {x.by_category.slice(0, 16).map((t) => (
98 + <li key={t.category} className="grid grid-cols-[10rem_1fr_3rem] items-center gap-2 text-[12.5px]">
99 + <Link href={`/category/${t.category}`} className="truncate hover:underline">{t.category}</Link>
100 + <Bar value={t.n} max={maxCat} tone="signal" />
101 + <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span>
102 + </li>
103 + ))}
104 + </ul>
105 + ) : (
106 + <Empty />
107 + )}
108 + </Panel>
109 + </div>
110 + </>
111 + );
112 +}
added apps/web/src/app/globals.css +161 −0
@@ -0,0 +1,161 @@
1 +@import "tailwindcss";
2 +
3 +@custom-variant dark (&:where(.dark, .dark *));
4 +
5 +@theme {
6 + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
7 + --font-mono: var(--font-jetbrains), ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
8 +
9 + --color-bg: var(--bg);
10 + --color-panel: var(--panel);
11 + --color-panel-2: var(--panel-2);
12 + --color-fg: var(--fg);
13 + --color-fg-muted: var(--fg-muted);
14 + --color-fg-subtle: var(--fg-subtle);
15 + --color-line: var(--line);
16 + --color-line-strong: var(--line-strong);
17 + --color-signal: var(--signal);
18 + --color-signal-soft: var(--signal-soft);
19 + --color-hot: var(--hot);
20 + --color-high: var(--high);
21 + --color-mid: var(--mid);
22 + --color-low: var(--low);
23 + --color-silent: var(--silent);
24 + --color-silent-soft: var(--silent-soft);
25 + --color-danger: var(--danger);
26 + --color-warn: var(--warn);
27 + --color-ok: var(--ok);
28 + --color-info: var(--info);
29 +
30 + --radius-sm: 3px;
31 + --radius-md: 5px;
32 + --radius-lg: 8px;
33 +
34 + --animate-pulse-dot: pulse-dot 1.6s ease-in-out infinite;
35 + --animate-flash: flash 1.4s ease-out both;
36 + --animate-fade-in: fade-in 0.2s ease-out both;
37 +
38 + @keyframes pulse-dot {
39 + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); }
40 + 60% { box-shadow: 0 0 0 6px transparent; }
41 + }
42 + @keyframes flash {
43 + 0% { background-color: color-mix(in oklab, var(--signal) 22%, transparent); }
44 + 100% { background-color: transparent; }
45 + }
46 + @keyframes fade-in {
47 + from { opacity: 0; transform: translateY(-2px); }
48 + to { opacity: 1; transform: translateY(0); }
49 + }
50 +}
51 +
52 +:root {
53 + color-scheme: light;
54 + --bg: #f7f8fb;
55 + --panel: #ffffff;
56 + --panel-2: #f0f2f6;
57 + --fg: #0d1220;
58 + --fg-muted: #4a5468;
59 + --fg-subtle: #7f889b;
60 + --line: #e2e6ee;
61 + --line-strong: #c6cdd9;
62 + --signal: #0f9f7d;
63 + --signal-soft: #dcf5ee;
64 + --hot: #d12b2b;
65 + --high: #e26a12;
66 + --mid: #b7860b;
67 + --low: #7f889b;
68 + --silent: #6d4fd6;
69 + --silent-soft: #ede8fb;
70 + --danger: #d12b2b;
71 + --warn: #c57a0a;
72 + --ok: #0f9f7d;
73 + --info: #2b63d9;
74 +}
75 +
76 +.dark {
77 + color-scheme: dark;
78 + --bg: #0b0f17;
79 + --panel: #111726;
80 + --panel-2: #161d2e;
81 + --fg: #e5e7eb;
82 + --fg-muted: #a3adc2;
83 + --fg-subtle: #6b7591;
84 + --line: #1f2937;
85 + --line-strong: #2f3b52;
86 + --signal: #22d3a5;
87 + --signal-soft: #0f2a24;
88 + --hot: #ff5c5c;
89 + --high: #ff9640;
90 + --mid: #e4b53b;
91 + --low: #6b7591;
92 + --silent: #a78bfa;
93 + --silent-soft: #241c3d;
94 + --danger: #ff5c5c;
95 + --warn: #e4b53b;
96 + --ok: #22d3a5;
97 + --info: #6ea0ff;
98 +}
99 +
100 +@layer base {
101 + * {
102 + border-color: var(--line);
103 + }
104 + html {
105 + -webkit-text-size-adjust: 100%;
106 + text-rendering: optimizeLegibility;
107 + }
108 + body {
109 + @apply bg-bg text-fg font-sans antialiased;
110 + font-size: 14px;
111 + line-height: 1.5;
112 + }
113 + ::selection {
114 + background: color-mix(in oklab, var(--signal) 30%, transparent);
115 + }
116 + :focus-visible {
117 + outline: 2px solid var(--signal);
118 + outline-offset: 1px;
119 + border-radius: 3px;
120 + }
121 + h1, h2, h3 {
122 + letter-spacing: -0.015em;
123 + }
124 + code, kbd, pre, samp {
125 + @apply font-mono;
126 + font-size: 0.92em;
127 + }
128 + ::-webkit-scrollbar {
129 + width: 10px;
130 + height: 10px;
131 + }
132 + ::-webkit-scrollbar-thumb {
133 + background: var(--line-strong);
134 + border-radius: 6px;
135 + border: 2px solid var(--bg);
136 + }
137 +}
138 +
139 +@utility hairline {
140 + border-bottom: 1px solid var(--line);
141 +}
142 +@utility tabular {
143 + font-variant-numeric: tabular-nums;
144 +}
145 +@utility panel {
146 + background: var(--panel);
147 + border: 1px solid var(--line);
148 + border-radius: var(--radius-lg);
149 +}
150 +@utility label {
151 + font-size: 10.5px;
152 + letter-spacing: 0.08em;
153 + text-transform: uppercase;
154 + color: var(--fg-subtle);
155 + font-weight: 600;
156 +}
157 +
158 +.diff-line-add { background: color-mix(in oklab, var(--ok) 14%, transparent); color: var(--fg); }
159 +.diff-line-del { background: color-mix(in oklab, var(--danger) 14%, transparent); color: var(--fg); }
160 +.diff-line-meta { color: var(--fg-subtle); }
161 +.diff-line-hunk { color: var(--info); background: color-mix(in oklab, var(--info) 8%, transparent); }
added apps/web/src/app/health/page.tsx +107 −0
@@ -0,0 +1,107 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { Chip, Empty, HealthPill, PageHeader, Panel, Stat, Table, Td } from "@/components/ui";
4 +import { api } from "@/lib/api";
5 +import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime } from "@/lib/format";
6 +
7 +export const dynamic = "force-dynamic";
8 +export const metadata: Metadata = { title: "Connector health", description: "Live health of every connector family and sensor: success rate, latency, HTTP codes, noise ratios." };
9 +
10 +export default async function HealthPage() {
11 + const h = await api.health();
12 + const byHealth = Object.fromEntries((h?.sensors_by_health ?? []).map((x) => [x.health, x.n]));
13 + return (
14 + <>
15 + <PageHeader kicker="Internal observability" title="Connector health" description="Every connector exposes UP / DEGRADED / ERROR / RATE_LIMITED with 24 h success rate, latency, changes and events. Noisy sensors point to canonicalizer work, not more LLM calls." />
16 + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0">
17 + <Stat label="Sensors UP" value={fmtInt(byHealth.UP ?? 0)} />
18 + <Stat label="Degraded" value={fmtInt(byHealth.DEGRADED ?? 0)} />
19 + <Stat label="Error" value={fmtInt(byHealth.ERROR ?? 0)} />
20 + <Stat label="Rate limited" value={fmtInt(byHealth.RATE_LIMITED ?? 0)} />
21 + <Stat label="WS clients" value={fmtInt(h?.live.clients ?? 0)} hint={`${fmtInt(h?.live.published ?? 0)} published since start`} />
22 + <Stat label="Connectors" value={fmtInt(h?.connectors.length ?? 0)} />
23 + </div>
24 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
25 + {(h?.connectors ?? []).map((c) => (
26 + <Panel key={c.connector} title={<span className="flex items-center gap-2 normal-case tracking-normal"><span className="font-mono text-[13px] text-fg">{c.connector}</span><HealthPill health={c.status} /></span>}>
27 + <dl className="grid grid-cols-2 gap-y-1 text-[12.5px]">
28 + <dt className="text-fg-subtle">Success rate</dt><dd className="text-right font-mono tabular">{fmtPct(c.success_rate ?? null, 1)}</dd>
29 + <dt className="text-fg-subtle">Runs 24 h</dt><dd className="text-right font-mono tabular">{fmtInt(c.runs_24h)}</dd>
30 + <dt className="text-fg-subtle">Errors 24 h</dt><dd className="text-right font-mono tabular">{fmtInt(c.errors_24h)}</dd>
31 + <dt className="text-fg-subtle">Avg latency</dt><dd className="text-right font-mono tabular">{fmtMs(c.avg_latency_ms)}</dd>
32 + <dt className="text-fg-subtle">Changes / events 24 h</dt><dd className="text-right font-mono tabular">{fmtInt(c.changes_24h)} / {fmtInt(c.events_24h)}</dd>
33 + <dt className="text-fg-subtle">Last success</dt><dd className="text-right font-mono tabular">{c.last_success_at ? relTime(c.last_success_at) : "—"}</dd>
34 + </dl>
35 + <div className="mt-2 flex flex-wrap gap-1">
36 + {Object.entries(c.http_codes ?? {}).sort((a, b) => b[1] - a[1]).map(([code, n]) => (
37 + <Chip key={code} tone={code.startsWith("2") || code === "304" ? "ok" : code.startsWith("4") ? "warn" : code === "0" || code.startsWith("5") ? "danger" : "default"}>{code} · {n}</Chip>
38 + ))}
39 + </div>
40 + {c.last_error && <p className="mt-2 truncate font-mono text-[11px] text-danger" title={c.last_error}>{c.last_error}</p>}
41 + </Panel>
42 + ))}
43 + {!h?.connectors.length && <Panel dense><Empty>Health rollups appear after the first minute of engine activity.</Empty></Panel>}
44 + </div>
45 + <div className="mt-4 grid gap-4 lg:grid-cols-2">
46 + <Panel title="Degraded sensors" dense>
47 + {h?.degraded_sensors.length ? (
48 + <Table head={["Sensor", "Source", "Health", "Errors", "HTTP", "Last error", "Last check"]}>
49 + {h.degraded_sensors.map((s) => (
50 + <tr key={s.id}>
51 + <Td><Link href={`/sensor/${s.id}`} className="hover:underline">{s.name}</Link><div className="max-w-[18rem] truncate font-mono text-[10.5px] text-fg-subtle">{s.url}</div></Td>
52 + <Td><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_id}</Link></Td>
53 + <Td><HealthPill health={s.health} /></Td>
54 + <Td mono>{s.consecutive_errors}</Td>
55 + <Td mono>{s.last_status ?? "—"}</Td>
56 + <Td className="max-w-[16rem] truncate text-danger" >{s.last_error}</Td>
57 + <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "—"}</Td>
58 + </tr>
59 + ))}
60 + </Table>
61 + ) : (
62 + <Empty>All sensors healthy.</Empty>
63 + )}
64 + </Panel>
65 + <Panel title="Noisy sensors (raw vs meaningful)" dense>
66 + {h?.noisy_sensors.length ? (
67 + <Table head={["Sensor", "Source", "Raw", "Meaningful", "Noise ratio"]}>
68 + {h.noisy_sensors.map((s) => (
69 + <tr key={s.id}>
70 + <Td><Link href={`/sensor/${s.id}`} className="hover:underline">{s.name}</Link></Td>
71 + <Td><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_id}</Link></Td>
72 + <Td mono>{s.raw_changes}</Td>
73 + <Td mono>{s.meaningful_changes}</Td>
74 + <Td mono className={(s.noise_ratio ?? 0) > 0.9 ? "text-warn" : ""}>{s.noise_ratio !== null ? fmtPct(Number(s.noise_ratio), 1) : "—"}</Td>
75 + </tr>
76 + ))}
77 + </Table>
78 + ) : (
79 + <Empty>Noise ratios appear once sensors have ≥ 5 raw changes.</Empty>
80 + )}
81 + </Panel>
82 + </div>
83 + <Panel title="Daily metrics" dense className="mt-4">
84 + {h?.daily.length ? (
85 + <Table head={["Day", "Checks", "304", "Bytes", "Raw changes", "Events", "Silent", "Errors", "LLM calls", "LLM tokens in/out"]}>
86 + {h.daily.map((d) => (
87 + <tr key={String(d.day)}>
88 + <Td mono>{String(d.day)}</Td>
89 + <Td mono>{fmtInt(Number(d.checks))}</Td>
90 + <Td mono>{fmtInt(Number(d.not_modified))}</Td>
91 + <Td mono>{fmtBytes(Number(d.bytes))}</Td>
92 + <Td mono>{fmtInt(Number(d.raw_changes))}</Td>
93 + <Td mono>{fmtInt(Number(d.events))}</Td>
94 + <Td mono>{fmtInt(Number(d.silent_events))}</Td>
95 + <Td mono>{fmtInt(Number(d.errors))}</Td>
96 + <Td mono>{fmtInt(Number(d.llm_calls))}</Td>
97 + <Td mono>{fmtInt(Number(d.llm_input_tokens))} / {fmtInt(Number(d.llm_output_tokens))}</Td>
98 + </tr>
99 + ))}
100 + </Table>
101 + ) : (
102 + <Empty />
103 + )}
104 + </Panel>
105 + </>
106 + );
107 +}
added apps/web/src/app/icon.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0b0f17"/><circle cx="32" cy="32" r="8" fill="#22d3a5"/><circle cx="32" cy="32" r="16" fill="none" stroke="#22d3a5" stroke-opacity="0.55" stroke-width="3"/><circle cx="32" cy="32" r="25" fill="none" stroke="#22d3a5" stroke-opacity="0.25" stroke-width="3"/></svg>
added apps/web/src/app/layout.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import type { Metadata, Viewport } from "next";
2 +import { Inter, JetBrains_Mono } from "next/font/google";
3 +import type { ReactNode } from "react";
4 +import { Footer } from "@/components/footer";
5 +import { MobileNav, TopNav } from "@/components/nav";
6 +import { Providers } from "@/components/theme";
7 +import { SITE_URL } from "@/lib/api";
8 +import "./globals.css";
9 +
10 +const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
11 +const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" });
12 +
13 +export const metadata: Metadata = {
14 + metadataBase: new URL(SITE_URL),
15 + title: { default: "WebSensor — Detect What Changed. Know Why It Matters.", template: "%s · WebSensor" },
16 + description: "Real-time intelligence from the changing Web. WebSensor monitors official public sources, detects meaningful changes, scores their importance and publishes them live.",
17 + applicationName: "WebSensor",
18 + openGraph: { type: "website", siteName: "WebSensor", url: SITE_URL, title: "WebSensor — The Web, Live.", description: "A global sensor network for the changing Web." },
19 + twitter: { card: "summary_large_image", title: "WebSensor — The Web, Live.", description: "Detect What Changed. Know Why It Matters." },
20 + alternates: { canonical: "/", types: { "application/rss+xml": `${SITE_URL}/api/v1/feed.rss` } },
21 + robots: { index: true, follow: true },
22 +};
23 +
24 +export const viewport: Viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#0b0f17" }, { media: "(prefers-color-scheme: light)", color: "#f7f8fb" }], width: "device-width", initialScale: 1 };
25 +
26 +export default function RootLayout({ children }: { children: ReactNode }) {
27 + return (
28 + <html lang="en" className={`${inter.variable} ${mono.variable} dark`} suppressHydrationWarning>
29 + <body className="min-h-dvh">
30 + <Providers>
31 + <TopNav />
32 + <main className="mx-auto w-full max-w-[1500px] px-3 py-4 sm:px-4">{children}</main>
33 + <Footer />
34 + <MobileNav />
35 + </Providers>
36 + </body>
37 + </html>
38 + );
39 +}
added apps/web/src/app/manifest.ts +14 −0
@@ -0,0 +1,14 @@
1 +import type { MetadataRoute } from "next";
2 +
3 +export default function manifest(): MetadataRoute.Manifest {
4 + return {
5 + name: "WebSensor",
6 + short_name: "WebSensor",
7 + description: "Detect What Changed. Know Why It Matters.",
8 + start_url: "/",
9 + display: "standalone",
10 + background_color: "#0b0f17",
11 + theme_color: "#0b0f17",
12 + icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
13 + };
14 +}
added apps/web/src/app/not-found.tsx +15 −0
@@ -0,0 +1,15 @@
1 +import Link from "next/link";
2 +
3 +export default function NotFound() {
4 + return (
5 + <div className="mx-auto max-w-lg py-20 text-center">
6 + <div className="label mb-2">404</div>
7 + <h1 className="text-xl font-semibold">Nothing observed at this address</h1>
8 + <p className="mt-2 text-[13px] text-fg-muted">The page may have moved, or the event, source or entity does not exist.</p>
9 + <div className="mt-5 flex justify-center gap-3 text-[13px]">
10 + <Link href="/" className="rounded-md border border-line bg-panel px-3 py-1.5 hover:border-line-strong">Live feed</Link>
11 + <Link href="/search" className="rounded-md border border-line bg-panel px-3 py-1.5 hover:border-line-strong">Search</Link>
12 + </div>
13 + </div>
14 + );
15 +}
added apps/web/src/app/opengraph-image.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import { ImageResponse } from "next/og";
2 +
3 +export const runtime = "nodejs";
4 +export const alt = "WebSensor — The Web, Live.";
5 +export const size = { width: 1200, height: 630 };
6 +export const contentType = "image/png";
7 +
8 +export default function OpenGraphImage() {
9 + return new ImageResponse(
10 + (
11 + <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", padding: 64, background: "#0b0f17", color: "#e5e7eb", fontFamily: "Inter, system-ui, sans-serif" }}>
12 + <div style={{ display: "flex", alignItems: "center", gap: 16, fontSize: 36, fontWeight: 700 }}>
13 + <div style={{ width: 20, height: 20, borderRadius: 20, background: "#22d3a5", boxShadow: "0 0 0 10px rgba(34,211,165,0.25)" }} />
14 + <span>
15 + Web<span style={{ color: "#22d3a5" }}>Sensor</span>
16 + </span>
17 + </div>
18 + <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
19 + <div style={{ display: "flex", flexDirection: "column", fontSize: 76, fontWeight: 700, letterSpacing: -2, lineHeight: 1.05 }}>
20 + <span>The Web is changing.</span>
21 + <span>We are watching.</span>
22 + </div>
23 + <div style={{ fontSize: 30, color: "#a3adc2" }}>Detect What Changed. Know Why It Matters.</div>
24 + </div>
25 + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 24, color: "#6b7591", fontFamily: "monospace" }}>
26 + <span>www.websensor.io</span>
27 + <span>LIVE · UTC</span>
28 + </div>
29 + </div>
30 + ),
31 + { ...size },
32 + );
33 +}
added apps/web/src/app/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { LiveFeed } from "@/components/live-feed";
2 +import { ClustersPanel, TrendingPanel } from "@/components/rail";
3 +import { StatsStrip } from "@/components/stats-strip";
4 +import { api } from "@/lib/api";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +export default async function LivePage() {
9 + const [stats, events, trending, clusters] = await Promise.all([api.stats(), api.events({ limit: 60 }), api.trending(24, 10), api.clusters(20, 48)]);
10 + return (
11 + <>
12 + <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
13 + <h1 className="text-[15px] font-semibold tracking-tight">
14 + The Web is changing. <span className="text-fg-muted">We are watching.</span>
15 + </h1>
16 + <p className="text-[12px] text-fg-subtle">Official sources · conditional fetches · immutable evidence · importance-scored events</p>
17 + </div>
18 + <StatsStrip stats={stats} />
19 + <div className="grid gap-4 lg:grid-cols-[1fr_320px]">
20 + <LiveFeed initial={events.items} initialCursor={events.nextCursor} />
21 + <aside className="hidden flex-col gap-4 lg:flex">
22 + <TrendingPanel items={trending.items} />
23 + <ClustersPanel items={clusters.items} />
24 + </aside>
25 + </div>
26 + </>
27 + );
28 +}
added apps/web/src/app/robots.ts +10 −0
@@ -0,0 +1,10 @@
1 +import type { MetadataRoute } from "next";
2 +import { SITE_URL } from "@/lib/api";
3 +
4 +export default function robots(): MetadataRoute.Robots {
5 + return {
6 + rules: [{ userAgent: "*", allow: "/", disallow: ["/api/v1/watchlists", "/api/v1/alerts", "/compare", "/url"] }],
7 + sitemap: `${SITE_URL}/sitemap.xml`,
8 + host: SITE_URL,
9 + };
10 +}
added apps/web/src/app/search/page.tsx +74 −0
@@ -0,0 +1,74 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { EventRow } from "@/components/event-row";
4 +import { Chip, Empty, PageHeader, Panel } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { relTime } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +export const metadata: Metadata = { title: "Search", robots: { index: false } };
10 +
11 +export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
12 + const { q = "" } = await searchParams;
13 + const r = q.trim().length >= 2 ? await api.search(q.trim()) : null;
14 + return (
15 + <>
16 + <PageHeader kicker="Search everything" title={q ? <span>Results for “{q}”</span> : "Search"} description="Events, entities, sources, URLs and domains. Full-text search over titles, summaries and keywords." />
17 + <form action="/search" className="mb-4 flex gap-2">
18 + <input name="q" defaultValue={q} autoFocus placeholder="OpenAI, CVE-2026, pricing, status.…" className="h-9 w-full max-w-xl rounded-md border border-line bg-panel px-3 text-[13.5px] placeholder:text-fg-subtle" />
19 + <button type="submit" className="h-9 rounded-md border border-line bg-panel-2 px-3 text-[13px]">Search</button>
20 + </form>
21 + {r && (
22 + <div className="grid gap-4 lg:grid-cols-[1fr_320px]">
23 + <Panel title={`Events · ${r.events.length}`} dense>
24 + {r.events.length ? r.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No events match.</Empty>}
25 + </Panel>
26 + <aside className="flex flex-col gap-4">
27 + <Panel title={`Entities · ${r.entities.length}`} dense>
28 + {r.entities.length ? (
29 + <ul className="divide-y divide-line">
30 + {r.entities.map((e) => (
31 + <li key={e.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
32 + <Link href={`/company/${e.id}`} className="hover:underline">{e.name}</Link>
33 + <Chip>{e.type}</Chip>
34 + </li>
35 + ))}
36 + </ul>
37 + ) : (
38 + <Empty>—</Empty>
39 + )}
40 + </Panel>
41 + <Panel title={`Sources · ${r.sources.length}`} dense>
42 + {r.sources.length ? (
43 + <ul className="divide-y divide-line">
44 + {r.sources.map((s) => (
45 + <li key={s.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
46 + <Link href={`/source/${s.id}`} className="hover:underline">{s.name}</Link>
47 + <Link href={`/domain/${s.domain}`} className="font-mono text-[11px] text-fg-subtle hover:underline">{s.domain}</Link>
48 + </li>
49 + ))}
50 + </ul>
51 + ) : (
52 + <Empty>—</Empty>
53 + )}
54 + </Panel>
55 + <Panel title={`URLs · ${r.urls.length}`} dense>
56 + {r.urls.length ? (
57 + <ul className="divide-y divide-line">
58 + {r.urls.map((u) => (
59 + <li key={u.url} className="px-3 py-1.5 text-[12px]">
60 + <Link href={`/url?u=${encodeURIComponent(u.url)}`} className="block truncate font-mono hover:underline">{u.url}</Link>
61 + <span className="text-[11px] text-fg-subtle">{u.change_count} changes · {relTime(u.last_seen_at)}</span>
62 + </li>
63 + ))}
64 + </ul>
65 + ) : (
66 + <Empty>—</Empty>
67 + )}
68 + </Panel>
69 + </aside>
70 + </div>
71 + )}
72 + </>
73 + );
74 +}
added apps/web/src/app/sensor/[id]/page.tsx +105 −0
@@ -0,0 +1,105 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import { Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui";
5 +import { api } from "@/lib/api";
6 +import { fmtBytes, fmtDuration, fmtInt, fmtMs, relTime, shortHash, untilTime, utcDateTime } from "@/lib/format";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
11 + const { id } = await params;
12 + const d = await api.sensor(id);
13 + return { title: d ? `${d.sensor.source_name} · ${d.sensor.name} — sensor` : "Sensor not found", robots: { index: false } };
14 +}
15 +
16 +export default async function SensorPage({ params }: { params: Promise<{ id: string }> }) {
17 + const { id } = await params;
18 + const d = await api.sensor(id);
19 + if (!d) notFound();
20 + const s = d.sensor;
21 + const runs = s.total_runs ?? 0;
22 + return (
23 + <>
24 + <PageHeader
25 + kicker={<span className="flex items-center gap-2"><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_name}</Link> · <span className="font-mono">{s.type}</span> · {s.connector} · <TierBadge tier={s.tier} /></span>}
26 + title={s.name}
27 + description={<ExtLink href={s.url} className="font-mono text-[12px] break-all">{s.url}</ExtLink>}
28 + actions={<><HealthPill health={s.enabled ? s.health : "DISABLED"} /><Link href={`/url?u=${encodeURIComponent(s.url)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">URL history →</Link></>}
29 + />
30 + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 lg:grid-cols-8 lg:divide-y-0">
31 + <Stat label="Runs" value={fmtInt(runs)} hint={`${runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : 0}% 304`} />
32 + <Stat label="Raw changes" value={fmtInt(s.raw_changes)} />
33 + <Stat label="Meaningful" value={fmtInt(s.meaningful_changes)} hint={s.raw_changes ? `noise ${Math.round((1 - (s.meaningful_changes ?? 0) / Math.max(1, s.raw_changes)) * 100)}%` : undefined} />
34 + <Stat label="Latency" value={fmtMs(s.avg_latency_ms)} />
35 + <Stat label="Last check" value={s.last_check_at ? relTime(s.last_check_at) : "never"} hint={s.last_status ? `HTTP ${s.last_status}` : undefined} />
36 + <Stat label="Next check" value={untilTime(s.next_check_at)} hint={s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "adaptive"} />
37 + <Stat label="Last change" value={s.last_change_at ? relTime(s.last_change_at) : "—"} />
38 + <Stat label="Errors" value={fmtInt(s.consecutive_errors)} hint={s.last_error ? <span className="text-danger">{s.last_error}</span> : "consecutive"} />
39 + </div>
40 + <div className="grid gap-4 lg:grid-cols-2">
41 + <Panel title={`Changes · ${d.changes.length}`} dense>
42 + {d.changes.length ? (
43 + <Table head={["Detected", "Kind", "Signal", "Noise", "Magnitude", "Heuristic", "Event", "Compare"]}>
44 + {d.changes.map((c) => (
45 + <tr key={c.id}>
46 + <Td mono className="text-fg-subtle">{utcDateTime(c.detected_at)}</Td>
47 + <Td mono>{c.kind}</Td>
48 + <Td mono className={c.signal >= 0.32 ? "text-signal" : "text-fg-subtle"}>{c.signal.toFixed(2)}</Td>
49 + <Td mono>{c.noise_ratio !== undefined ? `${Math.round(c.noise_ratio * 100)}%` : "—"}</Td>
50 + <Td mono>{c.magnitude ?? "—"}</Td>
51 + <Td>{c.heuristic_type ? <Chip>{c.heuristic_type.replace(/_/g, " ")}</Chip> : "—"}</Td>
52 + <Td>{c.event_id ? <Link href={`/event/${c.event_id}`} className="text-info hover:underline">event</Link> : <span className="text-fg-subtle">{c.meaningful ? "—" : "filtered"}</span>}</Td>
53 + <Td>{c.old_snapshot_id && c.new_snapshot_id ? <Link href={`/compare?a=${c.old_snapshot_id}&b=${c.new_snapshot_id}`} className="text-info hover:underline">diff</Link> : "—"}</Td>
54 + </tr>
55 + ))}
56 + </Table>
57 + ) : (
58 + <Empty>No changes detected yet.</Empty>
59 + )}
60 + </Panel>
61 + <Panel title={`Snapshots · ${d.snapshots.length}`} dense>
62 + {d.snapshots.length ? (
63 + <Table head={["Captured", "HTTP", "Type", "Size", "Canonical hash", "Confidence", ""]}>
64 + {d.snapshots.map((sn, i) => (
65 + <tr key={sn.id}>
66 + <Td mono className="text-fg-subtle">{utcDateTime(sn.captured_at)}</Td>
67 + <Td mono>{sn.http_status ?? "—"}</Td>
68 + <Td mono className="text-fg-subtle">{(sn.content_type ?? sn.mode ?? "").split(";")[0]}</Td>
69 + <Td mono>{fmtBytes(sn.content_length)}</Td>
70 + <Td mono className="text-fg-subtle">{shortHash(sn.canonical_hash)}</Td>
71 + <Td mono>{sn.extraction_confidence !== null && sn.extraction_confidence !== undefined ? sn.extraction_confidence.toFixed(2) : "—"}</Td>
72 + <Td>
73 + <a href={`/api/v1/snapshots/${sn.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">raw</a>
74 + {d.snapshots[i + 1] && <> · <Link href={`/compare?a=${d.snapshots[i + 1]!.id}&b=${sn.id}`} className="text-info hover:underline">vs previous</Link></>}
75 + </Td>
76 + </tr>
77 + ))}
78 + </Table>
79 + ) : (
80 + <Empty>No snapshots yet.</Empty>
81 + )}
82 + </Panel>
83 + </div>
84 + <Panel title={`Runs · ${d.runs.length}`} dense className="mt-4">
85 + {d.runs.length ? (
86 + <Table head={["Started", "Outcome", "HTTP", "Duration", "Bytes", "Method", "Error"]}>
87 + {d.runs.map((r) => (
88 + <tr key={r.id}>
89 + <Td mono className="text-fg-subtle">{utcDateTime(r.started_at)}</Td>
90 + <Td><Chip tone={r.outcome === "event" ? "signal" : r.outcome === "changed" ? "info" : r.outcome === "error" || r.outcome === "parse_error" ? "danger" : r.outcome === "rate_limited" || r.outcome === "missing" ? "warn" : "default"}>{r.outcome}</Chip></Td>
91 + <Td mono>{r.http_status ?? "—"}</Td>
92 + <Td mono>{fmtMs(r.duration_ms)}</Td>
93 + <Td mono>{fmtBytes(r.bytes)}</Td>
94 + <Td mono className="text-fg-subtle">{r.fetch_method ?? "—"}</Td>
95 + <Td className="max-w-[24rem] truncate text-danger" >{r.error ?? ""}</Td>
96 + </tr>
97 + ))}
98 + </Table>
99 + ) : (
100 + <Empty>No runs recorded yet.</Empty>
101 + )}
102 + </Panel>
103 + </>
104 + );
105 +}
added apps/web/src/app/silent/page.tsx +17 −0
@@ -0,0 +1,17 @@
1 +import type { Metadata } from "next";
2 +import { LiveFeed } from "@/components/live-feed";
3 +import { PageHeader } from "@/components/ui";
4 +import { api } from "@/lib/api";
5 +
6 +export const dynamic = "force-dynamic";
7 +export const metadata: Metadata = { title: "Silent changes", description: "Important changes detected without a corresponding public announcement." };
8 +
9 +export default async function SilentPage() {
10 + const events = await api.events({ silent_change: true, limit: 60 });
11 + return (
12 + <>
13 + <PageHeader kicker="Flagship signal" title={<span>⚠ Silent changes</span>} description="Pricing, terms, API limits, documentation or product pages that changed quietly — no announcement matched within the observation window. Each item links to the exact diff and both preserved snapshots." />
14 + <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed="silent" title="SILENT CHANGES" />
15 + </>
16 + );
17 +}
added apps/web/src/app/sitemap.ts +19 −0
@@ -0,0 +1,19 @@
1 +import type { MetadataRoute } from "next";
2 +import { api, SITE_URL } from "@/lib/api";
3 +
4 +export const dynamic = "force-dynamic";
5 +
6 +export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
7 + const now = new Date();
8 + const statics: MetadataRoute.Sitemap = ["", "/breaking", "/explore", "/sources", "/entities", "/silent", "/api", "/health", "/bot", "/watchlists", "/alerts", ...["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure"].map((c) => `/category/${c}`)].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === "" ? "always" : "hourly", priority: p === "" ? 1 : 0.7 }));
9 + const [events, sources, entities] = await Promise.all([api.events({ limit: 200 }), api.sources(), api.entities({ limit: 500 })]);
10 + const more = await (events.nextCursor ? api.events({ limit: 200, cursor: events.nextCursor }) : Promise.resolve({ items: [], nextCursor: null }));
11 + const third = await (more.nextCursor ? api.events({ limit: 100, cursor: more.nextCursor }) : Promise.resolve({ items: [], nextCursor: null }));
12 + return [
13 + ...statics,
14 + ...[...events.items, ...more.items, ...third.items].map((e) => ({ url: `${SITE_URL}/event/${e.slug}`, lastModified: new Date(e.detected_at), changeFrequency: "daily" as const, priority: Math.min(0.9, 0.4 + e.importance / 200) })),
15 + ...sources.items.map((s) => ({ url: `${SITE_URL}/source/${s.id}`, lastModified: s.last_event_at ? new Date(s.last_event_at) : now, changeFrequency: "hourly" as const, priority: 0.6 })),
16 + ...sources.items.map((s) => ({ url: `${SITE_URL}/domain/${s.domain}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.4 })),
17 + ...entities.items.map((e) => ({ url: `${SITE_URL}/company/${e.id}`, lastModified: e.last_event_at ? new Date(e.last_event_at) : now, changeFrequency: "hourly" as const, priority: 0.6 })),
18 + ];
19 +}
added apps/web/src/app/source/[id]/page.tsx +134 −0
@@ -0,0 +1,134 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import { EventRow } from "@/components/event-row";
5 +import { Bar, Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui";
6 +import { api } from "@/lib/api";
7 +import { fmtDuration, fmtInt, fmtMs, fmtScore, relTime, untilTime } from "@/lib/format";
8 +
9 +export const dynamic = "force-dynamic";
10 +
11 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
12 + const { id } = await params;
13 + const d = await api.source(id);
14 + if (!d) return { title: "Source not found" };
15 + return { title: `${d.source.name} — source`, description: d.source.description ?? `Sensors, activity and events for ${d.source.name} (${d.source.domain}).`, alternates: { canonical: `/source/${d.source.id}` } };
16 +}
17 +
18 +export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
19 + const { id } = await params;
20 + const d = await api.source(id);
21 + if (!d) notFound();
22 + const events = await api.events({ source: d.source.id, limit: 40 });
23 + const a = d.activity ?? {};
24 + const score = a.activity_score ?? 0;
25 + const entityId = d.entities.find((e) => e.type === "organization" || e.id.startsWith("org_"))?.id;
26 + return (
27 + <>
28 + <PageHeader
29 + kicker={<span className="flex items-center gap-2"><TierBadge tier={d.source.tier} /> Tier {d.source.tier} · <Link href={`/domain/${d.source.domain}`} className="font-mono hover:underline">{d.source.domain}</Link></span>}
30 + title={d.source.name}
31 + description={d.source.description}
32 + actions={
33 + <>
34 + {d.source.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}
35 + {entityId && <Link href={`/company/${entityId}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Entity & timeline →</Link>}
36 + <ExtLink href={d.source.homepage ?? `https://${d.source.domain}`} className="text-[12px]">Website ↗</ExtLink>
37 + </>
38 + }
39 + />
40 + <div className="grid gap-4 lg:grid-cols-[1fr_320px]">
41 + <div className="flex flex-col gap-4">
42 + <Panel title={`Sensors · ${d.sensors.length}`} dense>
43 + {d.sensors.length === 0 ? (
44 + <Empty>No sensors yet — discovery validates feeds, sitemaps and status pages before creating sensors.</Empty>
45 + ) : (
46 + <Table head={["Sensor", "Type", "Tier", "Health", "Last check", "Next", "Latency", "Raw / meaningful", "304 %", "Status"]}>
47 + {d.sensors.map((s) => {
48 + const runs = s.total_runs ?? 0;
49 + const nm = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : 0;
50 + return (
51 + <tr key={s.id} className="hover:bg-panel-2/60">
52 + <Td>
53 + <Link href={`/sensor/${s.id}`} className="font-medium hover:underline">{s.name}</Link>
54 + <div className="max-w-[28rem] truncate font-mono text-[11px] text-fg-subtle">{s.url}</div>
55 + </Td>
56 + <Td mono><span className="text-fg-muted">{s.type}</span><div className="text-[10.5px] text-fg-subtle">{s.connector}</div></Td>
57 + <Td><TierBadge tier={s.tier} /></Td>
58 + <Td><HealthPill health={s.enabled ? s.health : "DISABLED"} /></Td>
59 + <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "never"}</Td>
60 + <Td mono className="text-fg-subtle">{untilTime(s.next_check_at)}{s.base_interval_seconds ? <div className="text-[10.5px]">base {fmtDuration(s.base_interval_seconds)}</div> : null}</Td>
61 + <Td mono>{fmtMs(s.avg_latency_ms)}</Td>
62 + <Td mono>{s.raw_changes ?? 0} / <span className="text-signal">{s.meaningful_changes ?? 0}</span></Td>
63 + <Td mono>{runs ? `${nm}%` : "—"}<div className="text-[10.5px] text-fg-subtle">{s.has_etag ? "etag" : ""}{s.has_etag && s.has_last_modified ? "+" : ""}{s.has_last_modified ? "lm" : ""}</div></Td>
64 + <Td mono>{s.last_status ?? "—"}{s.last_error ? <div className="max-w-[14rem] truncate text-[10.5px] text-danger" title={s.last_error}>{s.last_error}</div> : null}</Td>
65 + </tr>
66 + );
67 + })}
68 + </Table>
69 + )}
70 + </Panel>
71 + <Panel title="Recent events" dense>
72 + {events.items.length ? events.items.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful events yet for this source.</Empty>}
73 + </Panel>
74 + {d.discovery.length > 0 && (
75 + <Panel title="Discovered endpoints" dense>
76 + <Table head={["Kind", "URL", "Evidence", "Items", "Value", "Status"]}>
77 + {d.discovery.map((c) => (
78 + <tr key={c.url}>
79 + <Td mono>{c.kind}</Td>
80 + <Td><span className="block max-w-[32rem] truncate font-mono text-[11.5px]">{c.url}</span></Td>
81 + <Td className="text-fg-subtle">{c.evidence}</Td>
82 + <Td mono>{c.score?.itemCount ?? "—"}</Td>
83 + <Td mono>{c.score?.value !== undefined ? fmtScore((c.score.value ?? 0) * 100) : "—"}</Td>
84 + <Td><Chip tone={c.status === "promoted" ? "ok" : c.status === "rejected" ? "danger" : "default"}>{c.status}</Chip></Td>
85 + </tr>
86 + ))}
87 + </Table>
88 + </Panel>
89 + )}
90 + </div>
91 + <aside className="flex flex-col gap-4">
92 + <Panel title="Activity anomaly">
93 + <div className="flex items-baseline justify-between">
94 + <span className={`font-mono text-3xl font-semibold tabular ${score >= 70 ? "text-hot" : score >= 45 ? "text-high" : "text-fg"}`}>{fmtScore(score)}</span>
95 + <span className="label">{score >= 70 ? "ACTIVITY ANOMALY" : score >= 45 ? "ELEVATED" : "NORMAL"}</span>
96 + </div>
97 + <div className="mt-2"><Bar value={score} tone={score >= 70 ? "hot" : score >= 45 ? "high" : "signal"} /></div>
98 + <dl className="mt-3 grid grid-cols-2 gap-y-1 text-[12.5px]">
99 + <dt className="text-fg-subtle">Normal</dt><dd className="text-right font-mono tabular">{a.baseline_changes_per_day ?? 0} changes/day</dd>
100 + <dt className="text-fg-subtle">Current</dt><dd className="text-right font-mono tabular">{a.changes_2h ?? 0} in 2 h</dd>
101 + <dt className="text-fg-subtle">Events 24 h</dt><dd className="text-right font-mono tabular">{a.events_24h ?? 0}</dd>
102 + <dt className="text-fg-subtle">Events 14 d</dt><dd className="text-right font-mono tabular">{a.events_14d ?? 0}</dd>
103 + </dl>
104 + </Panel>
105 + <div className="panel grid grid-cols-2 divide-x divide-line">
106 + <Stat label="Sensors" value={fmtInt(d.sensors.length)} hint={`${d.sensors.filter((s) => s.health === "UP").length} UP`} />
107 + <Stat label="Raw changes" value={fmtInt(d.sensors.reduce((n, s) => n + (s.raw_changes ?? 0), 0))} hint={`${fmtInt(d.sensors.reduce((n, s) => n + (s.meaningful_changes ?? 0), 0))} meaningful`} />
108 + </div>
109 + <Panel title="Entities" dense>
110 + {d.entities.length ? (
111 + <ul className="divide-y divide-line">
112 + {d.entities.map((e) => (
113 + <li key={e.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]">
114 + <Link href={`/company/${e.id}`} className="hover:underline">{e.name}</Link>
115 + <span className="font-mono text-[11px] text-fg-subtle">{e.type} · {e.event_count} ev</span>
116 + </li>
117 + ))}
118 + </ul>
119 + ) : (
120 + <Empty>No entities linked.</Empty>
121 + )}
122 + </Panel>
123 + <Panel title="Source policy">
124 + <dl className="grid grid-cols-2 gap-y-1 text-[12.5px]">
125 + <dt className="text-fg-subtle">robots.txt checked</dt><dd className="text-right font-mono tabular">{d.source.robots_checked_at ? relTime(d.source.robots_checked_at) : "—"}</dd>
126 + <dt className="text-fg-subtle">Importance weight</dt><dd className="text-right font-mono tabular">{d.source.importance_weight ?? 1}</dd>
127 + <dt className="text-fg-subtle">Acquisition</dt><dd className="text-right">official feeds first</dd>
128 + </dl>
129 + </Panel>
130 + </aside>
131 + </div>
132 + </>
133 + );
134 +}
added apps/web/src/app/sources/page.tsx +53 −0
@@ -0,0 +1,53 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { Chip, Empty, PageHeader, Panel, Table, Td, TierBadge } from "@/components/ui";
4 +import { api } from "@/lib/api";
5 +import { fmtInt, relTime } from "@/lib/format";
6 +
7 +export const dynamic = "force-dynamic";
8 +export const metadata: Metadata = { title: "Sources", description: "Every organization monitored by WebSensor, with its sensors and recent activity." };
9 +
10 +const CATS = ["ai", "cloud", "developer", "cyber", "consumer-tech", "semiconductors", "finance", "government", "statistics", "health", "pharma", "science", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards"];
11 +
12 +export default async function SourcesPage({ searchParams }: { searchParams: Promise<{ q?: string; category?: string }> }) {
13 + const sp = await searchParams;
14 + const { items } = await api.sources({ q: sp.q, category: sp.category });
15 + return (
16 + <>
17 + <PageHeader kicker={`${fmtInt(items.length)} organizations`} title="Sources" description="WebSensor monitors sensors, not merely domains: each organization exposes several official endpoints (feeds, status pages, sitemaps, pricing and documentation pages)." />
18 + <form className="mb-3 flex flex-wrap items-center gap-2" action="/sources">
19 + <input name="q" defaultValue={sp.q ?? ""} placeholder="Filter by name or domain…" className="h-8 w-64 rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle" />
20 + {sp.category && <input type="hidden" name="category" value={sp.category} />}
21 + <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px]">Filter</button>
22 + </form>
23 + <div className="mb-3 flex flex-wrap gap-1">
24 + <Chip href="/sources" tone={!sp.category ? "signal" : "default"}>all</Chip>
25 + {CATS.map((c) => (
26 + <Chip key={c} href={`/sources?category=${c}${sp.q ? `&q=${encodeURIComponent(sp.q)}` : ""}`} tone={sp.category === c ? "signal" : "default"}>{c}</Chip>
27 + ))}
28 + </div>
29 + <Panel dense>
30 + {items.length === 0 ? (
31 + <Empty>No sources match.</Empty>
32 + ) : (
33 + <Table head={["Tier", "Source", "Domain", "Categories", "Sensors", "Events 24h", "Total", "Last event", "Last check", "Health"]}>
34 + {items.map((s) => (
35 + <tr key={s.id} className="hover:bg-panel-2/60">
36 + <Td><TierBadge tier={s.tier} /></Td>
37 + <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td>
38 + <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td>
39 + <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/sources?category=${c}`}>{c}</Chip>)}</div></Td>
40 + <Td mono>{s.sensor_count ?? 0}</Td>
41 + <Td mono>{s.events_24h ?? 0}</Td>
42 + <Td mono>{fmtInt(s.event_count)}</Td>
43 + <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td>
44 + <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "—"}</Td>
45 + <Td>{(s.sensors_degraded ?? 0) > 0 ? <Chip tone="warn">{s.sensors_degraded} degraded</Chip> : <Chip tone="ok">UP</Chip>}</Td>
46 + </tr>
47 + ))}
48 + </Table>
49 + )}
50 + </Panel>
51 + </>
52 + );
53 +}
added apps/web/src/app/timeline/[id]/page.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import { Timeline } from "@/components/timeline";
5 +import { PageHeader, Panel } from "@/components/ui";
6 +import { api } from "@/lib/api";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
11 + const { id } = await params;
12 + const d = await api.entity(id);
13 + return { title: d ? `${d.entity.name} — full timeline` : "Timeline", alternates: { canonical: `/timeline/${id}` } };
14 +}
15 +
16 +export default async function TimelinePage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ cursor?: string }> }) {
17 + const { id } = await params;
18 + const { cursor } = await searchParams;
19 + const d = await api.entity(id);
20 + if (!d) notFound();
21 + const page = await api.entityTimeline(d.entity.id, cursor, 100);
22 + return (
23 + <>
24 + <PageHeader kicker={<Link href={`/company/${d.entity.id}`} className="hover:underline">← {d.entity.name}</Link>} title={`${d.entity.name} · timeline`} description="Every meaningful event, newest first. Git history for this organization's public Web." />
25 + <Panel dense>
26 + <Timeline events={page.items} showSource />
27 + <div className="flex justify-end px-3 py-2 text-[12px]">
28 + {page.nextCursor && <Link href={`/timeline/${d.entity.id}?cursor=${encodeURIComponent(page.nextCursor)}`} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 hover:border-line-strong">Older →</Link>}
29 + </div>
30 + </Panel>
31 + </>
32 + );
33 +}
added apps/web/src/app/url/compare-form.tsx +35 −0
@@ -0,0 +1,35 @@
1 +"use client";
2 +
3 +import { useRouter } from "next/navigation";
4 +import { useState } from "react";
5 +
6 +export function CompareForm({ snapshots }: { snapshots: { id: string; label: string }[] }) {
7 + const router = useRouter();
8 + const [a, setA] = useState(snapshots[1]?.id ?? "");
9 + const [b, setB] = useState(snapshots[0]?.id ?? "");
10 + if (snapshots.length < 2) return <p className="text-[12.5px] text-fg-subtle">At least two snapshots are needed to compare.</p>;
11 + const sel = "h-8 w-full rounded-md border border-line bg-panel px-2 font-mono text-[12px]";
12 + return (
13 + <form
14 + className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
15 + onSubmit={(e) => {
16 + e.preventDefault();
17 + if (a && b && a !== b) router.push(`/compare?a=${a}&b=${b}`);
18 + }}
19 + >
20 + <label className="text-[11px] text-fg-subtle">
21 + Before
22 + <select value={a} onChange={(e) => setA(e.target.value)} className={sel}>
23 + {snapshots.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
24 + </select>
25 + </label>
26 + <label className="text-[11px] text-fg-subtle">
27 + After
28 + <select value={b} onChange={(e) => setB(e.target.value)} className={sel}>
29 + {snapshots.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
30 + </select>
31 + </label>
32 + <button type="submit" className="self-end rounded-md border border-line bg-panel-2 px-3 py-1.5 text-[12.5px] hover:border-line-strong">Compare</button>
33 + </form>
34 + );
35 +}
added apps/web/src/app/url/page.tsx +72 −0
@@ -0,0 +1,72 @@
1 +import Link from "next/link";
2 +import type { Metadata } from "next";
3 +import { Chip, Empty, ExtLink, PageHeader, Panel, Table, Td } from "@/components/ui";
4 +import { api } from "@/lib/api";
5 +import { fmtBytes, shortHash, utcDateTime } from "@/lib/format";
6 +import { CompareForm } from "./compare-form";
7 +
8 +export const dynamic = "force-dynamic";
9 +export const metadata: Metadata = { title: "URL history", robots: { index: false } };
10 +
11 +export default async function UrlPage({ searchParams }: { searchParams: Promise<{ u?: string }> }) {
12 + const { u } = await searchParams;
13 + if (!u) {
14 + return (
15 + <>
16 + <PageHeader title="URL history" description="Paste a monitored URL to see its snapshots and changes." />
17 + <form action="/url" className="flex gap-2">
18 + <input name="u" placeholder="https://…" className="h-8 w-full max-w-xl rounded-md border border-line bg-panel px-2.5 font-mono text-[12.5px]" />
19 + <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px]">Look up</button>
20 + </form>
21 + </>
22 + );
23 + }
24 + const d = await api.urlHistory(u);
25 + const info = d?.url;
26 + return (
27 + <>
28 + <PageHeader kicker={<Link href={`/domain/${info?.domain ?? ""}`} className="font-mono hover:underline">{info?.domain}</Link>} title={<span className="break-all font-mono text-lg">{u}</span>} actions={<><ExtLink href={u} className="text-[12px]">Open ↗</ExtLink>{info?.status && <Chip tone={info.status === "active" ? "ok" : info.status === "removed" ? "danger" : "warn"}>{info.status}</Chip>}</>} />
29 + <div className="grid gap-4 lg:grid-cols-[1fr_1fr]">
30 + <Panel title={`History · ${d?.history.length ?? 0}`} dense>
31 + {d?.history.length ? (
32 + <Table head={["When", "Kind", "Detail"]}>
33 + {d.history.map((h) => (
34 + <tr key={h.id}>
35 + <Td mono className="text-fg-subtle">{utcDateTime(h.at)}</Td>
36 + <Td><Chip tone={h.kind === "event" ? "signal" : h.kind === "removed" ? "danger" : h.kind === "change" ? "info" : "default"}>{h.kind}</Chip></Td>
37 + <Td>
38 + {h.event_slug ? <Link href={`/event/${h.event_slug}`} className="hover:underline">{h.event_title}</Link> : h.change_id ? <span className="text-fg-muted">{h.change_kind} change · signal {h.signal?.toFixed(2)}</span> : h.note ?? <span className="text-fg-subtle">snapshot {h.snapshot_id}</span>}
39 + </Td>
40 + </tr>
41 + ))}
42 + </Table>
43 + ) : (
44 + <Empty>No history for this URL.</Empty>
45 + )}
46 + </Panel>
47 + <div className="flex flex-col gap-4">
48 + <Panel title="Compare any two versions">
49 + <CompareForm snapshots={(d?.snapshots ?? []).map((s) => ({ id: s.id, label: `${utcDateTime(s.captured_at)} · ${shortHash(s.canonical_hash, 8)}` }))} />
50 + </Panel>
51 + <Panel title={`Snapshots · ${d?.snapshots.length ?? 0}`} dense>
52 + {d?.snapshots.length ? (
53 + <Table head={["Captured", "HTTP", "Size", "Hash", ""]}>
54 + {d.snapshots.map((s) => (
55 + <tr key={s.id}>
56 + <Td mono className="text-fg-subtle">{utcDateTime(s.captured_at)}</Td>
57 + <Td mono>{s.http_status ?? "—"}</Td>
58 + <Td mono>{fmtBytes(s.content_length)}</Td>
59 + <Td mono className="text-fg-subtle">{shortHash(s.canonical_hash)}</Td>
60 + <Td><a href={`/api/v1/snapshots/${s.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">raw</a></Td>
61 + </tr>
62 + ))}
63 + </Table>
64 + ) : (
65 + <Empty>No snapshots.</Empty>
66 + )}
67 + </Panel>
68 + </div>
69 + </div>
70 + </>
71 + );
72 +}
added apps/web/src/app/watchlists/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { Metadata } from "next";
2 +import { PageHeader } from "@/components/ui";
3 +import { Watchlists } from "./watchlists";
4 +
5 +export const metadata: Metadata = { title: "Watchlists", description: "Follow organizations, sources, keywords and categories. Live updates via WebSocket.", robots: { index: false } };
6 +
7 +export default function WatchlistsPage() {
8 + return (
9 + <>
10 + <PageHeader kicker="Stored in this browser (no account yet)" title="Watchlists" description="Follow entities, sources, keywords or categories. Matching events stream in live. Accounts, email and webhook delivery are planned." />
11 + <Watchlists />
12 + </>
13 + );
14 +}
added apps/web/src/app/watchlists/watchlists.tsx +154 −0
@@ -0,0 +1,154 @@
1 +"use client";
2 +
3 +import { Plus, Trash2, X } from "lucide-react";
4 +import { useCallback, useEffect, useMemo, useState } from "react";
5 +import { EventRow } from "@/components/event-row";
6 +import { LiveDot } from "@/components/live-feed";
7 +import { Chip, Empty, Panel } from "@/components/ui";
8 +import { liveToEvent, type EventItem, type LiveEvent, type SearchResult, type Watchlist } from "@/lib/api";
9 +import { CHANNEL_KEYS } from "@/lib/format";
10 +import { ownerFetch, publicFetch } from "@/lib/owner";
11 +import { useLive } from "@/lib/use-live";
12 +
13 +type Item = { kind: "entity" | "source" | "keyword" | "category"; value: string; label?: string };
14 +
15 +export function Watchlists() {
16 + const [lists, setLists] = useState<Watchlist[] | null>(null);
17 + const [active, setActive] = useState<string | null>(null);
18 + const [events, setEvents] = useState<EventItem[]>([]);
19 + const [error, setError] = useState<string | null>(null);
20 + const [name, setName] = useState("");
21 + const [q, setQ] = useState("");
22 + const [results, setResults] = useState<SearchResult | null>(null);
23 +
24 + const load = useCallback(
25 + () =>
26 + ownerFetch<{ items: Watchlist[] }>("/api/v1/watchlists")
27 + .then((r) => {
28 + setLists(r.items);
29 + setActive((cur) => cur ?? r.items[0]?.id ?? null);
30 + })
31 + .catch((e: Error) => {
32 + setError(e.message);
33 + setLists([]);
34 + }),
35 + [],
36 + );
37 + useEffect(() => {
38 + void load();
39 + }, [load]);
40 +
41 + useEffect(() => {
42 + if (!active) return;
43 + ownerFetch<{ items: EventItem[] }>(`/api/v1/watchlists/${active}/events?limit=60`).then((r) => setEvents(r.items)).catch(() => setEvents([]));
44 + }, [active, lists]);
45 +
46 + useEffect(() => {
47 + const t = setTimeout(() => {
48 + if (q.trim().length < 2) {
49 + setResults(null);
50 + return;
51 + }
52 + publicFetch<SearchResult>(`/api/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`).then(setResults).catch(() => setResults(null));
53 + }, 250);
54 + return () => clearTimeout(t);
55 + }, [q]);
56 +
57 + const channels = useMemo(() => (active ? [`watchlist:${active}`] : []), [active]);
58 + const status = useLive(channels, (e: LiveEvent) => setEvents((prev) => (prev.some((x) => x.id === e.id) ? prev : [liveToEvent(e), ...prev].slice(0, 200))));
59 +
60 + const current = lists?.find((l) => l.id === active) ?? null;
61 +
62 + const create = async (): Promise<void> => {
63 + const wl = await ownerFetch<Watchlist>("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: name.trim() || "My watchlist", items: [] }) });
64 + setName("");
65 + await load();
66 + setActive(wl.id);
67 + };
68 + const remove = async (id: string): Promise<void> => {
69 + await ownerFetch(`/api/v1/watchlists/${id}`, { method: "DELETE" });
70 + setActive(null);
71 + await load();
72 + };
73 + const setItems = async (items: Item[]): Promise<void> => {
74 + if (!current) return;
75 + await ownerFetch(`/api/v1/watchlists/${current.id}`, { method: "PUT", body: JSON.stringify({ items: items.map((i) => ({ kind: i.kind, value: i.value })) }) });
76 + await load();
77 + };
78 + const add = (it: Item): void => {
79 + if (!current) return;
80 + const items = current.items.map((i) => ({ kind: i.kind as Item["kind"], value: i.value }));
81 + if (!items.some((i) => i.kind === it.kind && i.value === it.value)) void setItems([...items, it]);
82 + setQ("");
83 + };
84 + const del = (it: { kind: string; value: string }): void => {
85 + if (!current) return;
86 + void setItems(current.items.filter((i) => !(i.kind === it.kind && i.value === it.value)).map((i) => ({ kind: i.kind as Item["kind"], value: i.value })));
87 + };
88 +
89 + return (
90 + <div className="grid gap-4 lg:grid-cols-[300px_1fr]">
91 + <aside className="flex flex-col gap-4">
92 + <Panel title="Your watchlists" dense>
93 + {lists === null ? (
94 + <Empty>Loading…</Empty>
95 + ) : (
96 + <ul className="divide-y divide-line">
97 + {lists.map((l) => (
98 + <li key={l.id} className={`flex items-center justify-between px-3 py-1.5 text-[13px] ${l.id === active ? "bg-panel-2" : ""}`}>
99 + <button type="button" onClick={() => setActive(l.id)} className="min-w-0 flex-1 truncate text-left hover:underline">{l.name} <span className="text-fg-subtle">· {l.items.length}</span></button>
100 + <button type="button" aria-label="Delete" onClick={() => remove(l.id)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button>
101 + </li>
102 + ))}
103 + {lists.length === 0 && <Empty>No watchlist yet.</Empty>}
104 + </ul>
105 + )}
106 + <form
107 + className="flex gap-1 border-t border-line p-2"
108 + onSubmit={(e) => {
109 + e.preventDefault();
110 + void create();
111 + }}
112 + >
113 + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New watchlist name" className="h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 text-[12.5px]" />
114 + <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px]"><Plus className="size-3.5" /> Add</button>
115 + </form>
116 + {error && <p className="px-3 pb-2 text-[11px] text-danger">{error}</p>}
117 + </Panel>
118 + {current && (
119 + <Panel title={`Items · ${current.items.length}`}>
120 + <div className="mb-2 flex flex-wrap gap-1">
121 + {current.items.map((i) => (
122 + <Chip key={`${i.kind}:${i.value}`} tone={i.kind === "entity" ? "signal" : i.kind === "source" ? "info" : i.kind === "category" ? "ok" : "default"}>
123 + <span className="text-fg-subtle">{i.kind}</span> {i.value.replace(/^(org|prd)_/, "")}
124 + <button type="button" aria-label="Remove" onClick={() => del(i)} className="ml-0.5 hover:text-danger"><X className="size-3" /></button>
125 + </Chip>
126 + ))}
127 + {current.items.length === 0 && <span className="text-[12px] text-fg-subtle">Empty — add entities, sources, keywords or categories.</span>}
128 + </div>
129 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search entities / sources, or type a keyword…" className="h-8 w-full rounded-md border border-line bg-panel px-2 text-[12.5px]" />
130 + {q.trim().length >= 2 && (
131 + <div className="mt-1 max-h-64 overflow-auto rounded-md border border-line bg-panel text-[12.5px]">
132 + <button type="button" onClick={() => add({ kind: "keyword", value: q.trim().toLowerCase() })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">keyword “{q.trim()}”</button>
133 + {results?.entities.map((e) => (
134 + <button key={e.id} type="button" onClick={() => add({ kind: "entity", value: e.id })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">entity · {e.name} <span className="text-fg-subtle">{e.type}</span></button>
135 + ))}
136 + {results?.sources.map((s) => (
137 + <button key={s.id} type="button" onClick={() => add({ kind: "source", value: s.id })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">source · {s.name} <span className="text-fg-subtle">{s.domain}</span></button>
138 + ))}
139 + </div>
140 + )}
141 + <div className="mt-2 flex flex-wrap gap-1">
142 + {CHANNEL_KEYS.map((c) => (
143 + <button key={c} type="button" onClick={() => add({ kind: "category", value: c })} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] text-fg-muted hover:text-fg">+ {c}</button>
144 + ))}
145 + </div>
146 + </Panel>
147 + )}
148 + </aside>
149 + <Panel title={<span className="flex items-center gap-3">{current ? current.name : "Events"} {active && <LiveDot status={status} />}</span>} dense>
150 + {!current ? <Empty>Select or create a watchlist.</Empty> : events.length ? events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No events match this watchlist yet — new ones will appear live.</Empty>}
151 + </Panel>
152 + </div>
153 + );
154 +}
added apps/web/src/components/code.tsx +27 −0
@@ -0,0 +1,27 @@
1 +"use client";
2 +
3 +import { Check, Copy } from "lucide-react";
4 +import { useState } from "react";
5 +
6 +export function Code({ children, lang = "bash" }: { children: string; lang?: string }) {
7 + const [done, setDone] = useState(false);
8 + return (
9 + <div className="relative">
10 + <pre className="overflow-x-auto rounded-md border border-line bg-panel-2 p-3 pr-10 font-mono text-[12px] leading-5" data-lang={lang}>
11 + {children}
12 + </pre>
13 + <button
14 + type="button"
15 + aria-label="Copy"
16 + onClick={async () => {
17 + await navigator.clipboard.writeText(children).catch(() => undefined);
18 + setDone(true);
19 + setTimeout(() => setDone(false), 1200);
20 + }}
21 + className="absolute right-2 top-2 rounded border border-line bg-panel p-1 text-fg-subtle hover:text-fg"
22 + >
23 + {done ? <Check className="size-3.5 text-signal" /> : <Copy className="size-3.5" />}
24 + </button>
25 + </div>
26 + );
27 +}
added apps/web/src/components/diff-viewer.tsx +165 −0
@@ -0,0 +1,165 @@
1 +"use client";
2 +
3 +import { useMemo, useState } from "react";
4 +import type { DiffSummary } from "@/lib/api";
5 +
6 +type Tab = "unified" | "split" | "semantic" | "raw";
7 +
8 +interface Line {
9 + kind: "add" | "del" | "ctx" | "hunk" | "meta";
10 + text: string;
11 +}
12 +
13 +function parseUnified(patch: string): Line[] {
14 + return patch.split("\n").map((l): Line => {
15 + if (l.startsWith("+++") || l.startsWith("---") || l.startsWith("Index:") || l.startsWith("====")) return { kind: "meta", text: l };
16 + if (l.startsWith("@@")) return { kind: "hunk", text: l };
17 + if (l.startsWith("+")) return { kind: "add", text: l.slice(1) };
18 + if (l.startsWith("-")) return { kind: "del", text: l.slice(1) };
19 + return { kind: "ctx", text: l.startsWith(" ") ? l.slice(1) : l };
20 + });
21 +}
22 +
23 +/** Pair deletions and additions inside each hunk into left/right rows. */
24 +function splitRows(lines: Line[]): { left: Line | null; right: Line | null }[] {
25 + const rows: { left: Line | null; right: Line | null }[] = [];
26 + let i = 0;
27 + while (i < lines.length) {
28 + const l = lines[i]!;
29 + if (l.kind === "ctx" || l.kind === "hunk" || l.kind === "meta") {
30 + rows.push({ left: l, right: l });
31 + i++;
32 + continue;
33 + }
34 + const dels: Line[] = [];
35 + const adds: Line[] = [];
36 + while (i < lines.length && lines[i]!.kind === "del") dels.push(lines[i++]!);
37 + while (i < lines.length && lines[i]!.kind === "add") adds.push(lines[i++]!);
38 + const n = Math.max(dels.length, adds.length);
39 + for (let k = 0; k < n; k++) rows.push({ left: dels[k] ?? null, right: adds[k] ?? null });
40 + }
41 + return rows;
42 +}
43 +
44 +const cls = (k: Line["kind"] | undefined): string => (k === "add" ? "diff-line-add" : k === "del" ? "diff-line-del" : k === "hunk" ? "diff-line-hunk" : k === "meta" ? "diff-line-meta" : "");
45 +
46 +export function DiffViewer({ unified, summary, defaultTab = "semantic" }: { unified: string | null; summary: DiffSummary | null | undefined; defaultTab?: Tab }) {
47 + const [tab, setTab] = useState<Tab>(unified ? defaultTab : "semantic");
48 + const lines = useMemo(() => (unified ? parseUnified(unified) : []), [unified]);
49 + const rows = useMemo(() => splitRows(lines), [lines]);
50 + const tabs: [Tab, string][] = [
51 + ["unified", "Unified"],
52 + ["split", "Side-by-side"],
53 + ["semantic", "Semantic"],
54 + ["raw", "Raw"],
55 + ];
56 + return (
57 + <div className="panel overflow-hidden">
58 + <div className="flex items-center gap-1 border-b border-line px-2 py-1.5">
59 + {tabs.map(([k, label]) => (
60 + <button key={k} type="button" onClick={() => setTab(k)} className={`rounded-md px-2 py-1 text-[12px] ${tab === k ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"}`}>
61 + {label}
62 + </button>
63 + ))}
64 + {summary?.stats && (
65 + <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">
66 + <span className="text-ok">+{summary.stats.added}</span> <span className="text-danger">−{summary.stats.removed}</span> <span className="text-info">~{summary.stats.modified}</span>
67 + </span>
68 + )}
69 + {summary?.counts && (
70 + <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">
71 + <span className="text-ok">+{summary.counts.added}</span> <span className="text-danger">−{summary.counts.removed}</span> <span className="text-info">~{summary.counts.modified}</span>
72 + </span>
73 + )}
74 + </div>
75 + <div className="max-h-[70vh] overflow-auto font-mono text-[12px] leading-5">
76 + {tab === "unified" && (lines.length ? lines.map((l, i) => (
77 + <div key={i} className={`grid grid-cols-[1.25rem_1fr] whitespace-pre-wrap break-words px-2 ${cls(l.kind)}`}>
78 + <span className="select-none text-fg-subtle">{l.kind === "add" ? "+" : l.kind === "del" ? "−" : " "}</span>
79 + <span>{l.text}</span>
80 + </div>
81 + )) : <NoPatch />)}
82 + {tab === "split" && (rows.length ? (
83 + <div className="grid grid-cols-2 divide-x divide-line">
84 + <div>{rows.map((r, i) => <div key={i} className={`min-h-5 whitespace-pre-wrap break-words px-2 ${cls(r.left?.kind === "add" ? "ctx" : r.left?.kind)}`}>{r.left?.kind === "add" ? "" : r.left?.text ?? ""}</div>)}</div>
85 + <div>{rows.map((r, i) => <div key={i} className={`min-h-5 whitespace-pre-wrap break-words px-2 ${cls(r.right?.kind === "del" ? "ctx" : r.right?.kind)}`}>{r.right?.kind === "del" ? "" : r.right?.text ?? ""}</div>)}</div>
86 + </div>
87 + ) : <NoPatch />)}
88 + {tab === "semantic" && <Semantic summary={summary} />}
89 + {tab === "raw" && (unified ? <pre className="whitespace-pre-wrap break-words p-3">{unified}</pre> : <NoPatch />)}
90 + </div>
91 + </div>
92 + );
93 +}
94 +
95 +function NoPatch() {
96 + return <div className="p-4 text-fg-subtle">No textual patch stored for this change.</div>;
97 +}
98 +
99 +function Semantic({ summary }: { summary: DiffSummary | null | undefined }) {
100 + if (!summary) return <div className="p-4 text-fg-subtle">No structured summary.</div>;
101 + const str = (v: unknown): string => (typeof v === "string" ? v : v && typeof v === "object" ? String((v as { title?: unknown; url?: unknown; key?: unknown }).title ?? (v as { url?: unknown }).url ?? (v as { key?: unknown }).key ?? JSON.stringify(v)) : JSON.stringify(v));
102 + if (summary.kind === "json") {
103 + return (
104 + <div className="p-2">
105 + {(summary.changes ?? []).map((c, i) => (
106 + <div key={i} className="grid grid-cols-[auto_1fr] gap-x-3 px-2 py-1 hairline">
107 + <span className={c.op === "add" ? "text-ok" : c.op === "remove" ? "text-danger" : "text-info"}>{c.op}</span>
108 + <span className="whitespace-pre-wrap break-words">
109 + <span className="text-fg-muted">{c.path}</span>
110 + {c.op !== "add" && <span className="diff-line-del ml-2 rounded px-1">{JSON.stringify(c.before)}</span>}
111 + {c.op !== "remove" && <span className="diff-line-add ml-2 rounded px-1">{JSON.stringify(c.after)}</span>}
112 + </span>
113 + </div>
114 + ))}
115 + {!summary.changes?.length && <NoPatch />}
116 + </div>
117 + );
118 + }
119 + const mods = (summary.modified ?? []) as ({ before: unknown; after: unknown; fields?: string[]; key?: string })[];
120 + return (
121 + <div className="p-2 text-[12.5px]">
122 + {(summary.added ?? []).length > 0 && (
123 + <Section title={summary.kind === "list" ? "New items" : "Added"} tone="ok">
124 + {(summary.added ?? []).map((a, i) => (
125 + <Item key={i} tone="ok">
126 + {str(a)}
127 + {typeof a === "object" && a && (a as { summary?: string }).summary && <div className="text-fg-muted">{String((a as { summary?: string }).summary).slice(0, 400)}</div>}
128 + {typeof a === "object" && a && (a as { url?: string }).url && (a as { title?: string }).title && <div className="truncate text-fg-subtle">{String((a as { url?: string }).url)}</div>}
129 + </Item>
130 + ))}
131 + </Section>
132 + )}
133 + {(summary.removed ?? []).length > 0 && (
134 + <Section title={summary.kind === "list" ? "Removed items" : "Removed"} tone="danger">
135 + {(summary.removed ?? []).map((a, i) => <Item key={i} tone="danger">{str(a)}</Item>)}
136 + </Section>
137 + )}
138 + {mods.length > 0 && (
139 + <Section title={summary.kind === "list" ? "Updated items" : "Modified lines"} tone="info">
140 + {mods.map((m, i) => (
141 + <div key={i} className="px-2 py-1 hairline">
142 + {m.fields && <div className="text-[11px] text-fg-subtle">{str(m.after)} · {m.fields.join(", ")}</div>}
143 + <div className="diff-line-del whitespace-pre-wrap break-words rounded px-1">{m.fields ? m.fields.map((f) => `${f}: ${JSON.stringify((m.before as Record<string, unknown>)[f])}`).join(" · ") : str(m.before)}</div>
144 + <div className="diff-line-add mt-0.5 whitespace-pre-wrap break-words rounded px-1">{m.fields ? m.fields.map((f) => `${f}: ${JSON.stringify((m.after as Record<string, unknown>)[f])}`).join(" · ") : str(m.after)}</div>
145 + </div>
146 + ))}
147 + </Section>
148 + )}
149 + {summary.truncated && <div className="px-2 py-1 text-[11px] text-fg-subtle">Summary truncated — see the Unified tab for the full patch.</div>}
150 + {!(summary.added ?? []).length && !(summary.removed ?? []).length && !mods.length && <NoPatch />}
151 + </div>
152 + );
153 +}
154 +
155 +function Section({ title, tone, children }: { title: string; tone: "ok" | "danger" | "info"; children: React.ReactNode }) {
156 + return (
157 + <div className="mb-2">
158 + <div className={`label mb-1 px-2 ${tone === "ok" ? "!text-ok" : tone === "danger" ? "!text-danger" : "!text-info"}`}>{title}</div>
159 + {children}
160 + </div>
161 + );
162 +}
163 +function Item({ tone, children }: { tone: "ok" | "danger"; children: React.ReactNode }) {
164 + return <div className={`whitespace-pre-wrap break-words rounded px-2 py-0.5 ${tone === "ok" ? "diff-line-add" : "diff-line-del"}`}>{children}</div>;
165 +}
added apps/web/src/components/event-row.tsx +48 −0
@@ -0,0 +1,48 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import type { EventItem } from "@/lib/api";
5 +import { relTime, utcTime, utcDate } from "@/lib/format";
6 +import { Chip, EvidenceTag, Score, SilentBadge, TypeChip } from "./ui";
7 +
8 +export function EventRow({ ev, flash = false, showDate = false, now }: { ev: EventItem; flash?: boolean; showDate?: boolean; now?: number }) {
9 + const cats = (ev.categories ?? []).filter((c) => !["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure"].includes(c) || !ev.categories.some((x) => x !== c && sameChannel(c, x))).slice(0, 3);
10 + return (
11 + <article className={`grid grid-cols-[auto_1fr] gap-x-3 px-3 py-2 hairline sm:grid-cols-[6.5rem_1fr_auto] ${flash ? "animate-flash" : ""}`}>
12 + <div className="flex flex-col font-mono text-[11.5px] leading-4 text-fg-subtle tabular">
13 + <span className="text-fg-muted">{utcTime(ev.detected_at)}</span>
14 + <span>{showDate ? utcDate(ev.detected_at) : relTime(ev.detected_at, now)}</span>
15 + </div>
16 + <div className="min-w-0 sm:col-start-2">
17 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
18 + <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">
19 + {ev.source?.name ?? ev.source_id}
20 + </Link>
21 + <span className="sm:hidden">
22 + <Score value={ev.importance} size="sm" />
23 + </span>
24 + {ev.silent_change && <SilentBadge compact />}
25 + </div>
26 + <Link href={`/event/${ev.slug}`} className="mt-0.5 block text-[13.5px] font-medium leading-snug text-fg hover:underline">
27 + {ev.title}
28 + </Link>
29 + <div className="mt-1 flex flex-wrap items-center gap-1">
30 + {cats.map((c) => (
31 + <Chip key={c} href={`/category/${c}`}>{c}</Chip>
32 + ))}
33 + <TypeChip type={ev.event_type} />
34 + <EvidenceTag label={ev.evidence_label} />
35 + {ev.cluster_size && ev.cluster_size > 1 && <Chip tone="info" href={`/event/${ev.slug}#cluster`}>+{ev.cluster_size - 1} related</Chip>}
36 + </div>
37 + </div>
38 + <div className="hidden items-start pt-0.5 sm:flex">
39 + <Score value={ev.importance} />
40 + </div>
41 + </article>
42 + );
43 +}
44 +
45 +function sameChannel(a: string, b: string): boolean {
46 + const groups: Record<string, string[]> = { finance: ["finance", "payments", "crypto", "commerce"], health: ["health", "pharma"], government: ["government", "statistics"], science: ["science", "space"], products: ["consumer-tech", "automotive", "semiconductors", "enterprise"], infrastructure: ["cloud", "developer", "internet", "standards"] };
47 + return groups[a]?.includes(b) ?? false;
48 +}
added apps/web/src/components/footer.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import Link from "next/link";
2 +
3 +export function Footer() {
4 + return (
5 + <footer className="mt-10 border-t border-line pb-20 lg:pb-6">
6 + <div className="mx-auto flex max-w-[1500px] flex-wrap items-center justify-between gap-3 px-3 py-4 text-[12px] text-fg-subtle sm:px-4">
7 + <div>
8 + <span className="font-semibold text-fg-muted">WebSensor</span> — Detect What Changed. Know Why It Matters.
9 + </div>
10 + <nav className="flex flex-wrap gap-3">
11 + <Link href="/api" className="hover:text-fg">API</Link>
12 + <Link href="/health" className="hover:text-fg">Health</Link>
13 + <Link href="/bot" className="hover:text-fg">Bot</Link>
14 + <a href="/api/v1/feed.rss" className="hover:text-fg">RSS</a>
15 + <Link href="/explore" className="hover:text-fg">Explore</Link>
16 + </nav>
17 + <div className="font-mono text-[11px]">All timestamps UTC</div>
18 + </div>
19 + </footer>
20 + );
21 +}
added apps/web/src/components/live-feed.tsx +135 −0
@@ -0,0 +1,135 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5 +import { liveToEvent, type EventItem, type EventQuery, type LiveEvent, eventQueryString } from "@/lib/api";
6 +import { CHANNELS } from "@/lib/format";
7 +import { publicFetch } from "@/lib/owner";
8 +import { useLive, type LiveStatus } from "@/lib/use-live";
9 +import { EventRow } from "./event-row";
10 +import { Empty } from "./ui";
11 +
12 +const MAX_ROWS = 300;
13 +
14 +export function LiveDot({ status }: { status: LiveStatus }) {
15 + const label = status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE";
16 + const color = status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn";
17 + return (
18 + <span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-fg-muted">
19 + <span className={`inline-block size-2 rounded-full ${color}`} />
20 + {label}
21 + </span>
22 + );
23 +}
24 +
25 +/**
26 + * The live feed: initial REST page, WebSocket prepends, channel tabs, cursor pagination.
27 + * `fixed` restricts the feed to one filter (used by /breaking, /silent, /category/*).
28 + */
29 +export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title = "LIVE WEB", extraQuery }: { initial: EventItem[]; initialCursor: string | null; fixed?: string; showTabs?: boolean; title?: string; extraQuery?: EventQuery }) {
30 + const [tab, setTab] = useState(fixed ?? "all");
31 + const [items, setItems] = useState<EventItem[]>(initial);
32 + const [cursor, setCursor] = useState<string | null>(initialCursor);
33 + const [loading, setLoading] = useState(false);
34 + const [fresh, setFresh] = useState<Set<string>>(new Set());
35 + const [now, setNow] = useState(() => Date.now());
36 + const seen = useRef(new Set(initial.map((i) => i.id)));
37 + const chan = useMemo(() => CHANNELS.find((c) => c.key === tab) ?? CHANNELS[0]!, [tab]);
38 +
39 + useEffect(() => {
40 + const t = setInterval(() => setNow(Date.now()), 10_000);
41 + return () => clearInterval(t);
42 + }, []);
43 +
44 + const load = useCallback(
45 + async (reset: boolean, c: string | null) => {
46 + setLoading(true);
47 + try {
48 + const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), limit: 60, cursor: reset ? undefined : (c ?? undefined) };
49 + if (chan.key === "breaking") {
50 + q.order = "importance";
51 + q.after = new Date(Date.now() - 48 * 3600e3).toISOString();
52 + }
53 + const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString(q)}`);
54 + setItems((prev) => {
55 + const base = reset ? [] : prev;
56 + const ids = new Set(base.map((i) => i.id));
57 + const merged = [...base, ...page.items.filter((i) => !ids.has(i.id))];
58 + seen.current = new Set(merged.map((i) => i.id));
59 + return merged;
60 + });
61 + setCursor(page.nextCursor);
62 + } catch {
63 + // keep current list
64 + } finally {
65 + setLoading(false);
66 + }
67 + },
68 + [chan, extraQuery],
69 + );
70 +
71 + const first = useRef(true);
72 + useEffect(() => {
73 + if (first.current) {
74 + first.current = false;
75 + return;
76 + }
77 + void load(true, null);
78 + }, [load]);
79 +
80 + const onEvent = useCallback(
81 + (e: LiveEvent) => {
82 + if (seen.current.has(e.id)) return;
83 + const ev = liveToEvent(e);
84 + if (extraQuery?.source && ev.source_id !== extraQuery.source) return;
85 + if (extraQuery?.entity && !ev.entities.some((x) => x.id === extraQuery.entity)) return;
86 + seen.current.add(e.id);
87 + setItems((prev) => [ev, ...prev].slice(0, MAX_ROWS));
88 + setFresh((prev) => new Set([...prev, e.id]));
89 + setTimeout(() => setFresh((prev) => {
90 + const n = new Set(prev);
91 + n.delete(e.id);
92 + return n;
93 + }), 1500);
94 + },
95 + [extraQuery],
96 + );
97 + const status = useLive([chan.ws], onEvent);
98 +
99 + return (
100 + <section className="panel overflow-hidden">
101 + <header className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2">
102 + <h2 className="label mr-2 !text-fg">{title}</h2>
103 + <LiveDot status={status} />
104 + <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">{items.length} events</span>
105 + </header>
106 + {showTabs && !fixed && (
107 + <div className="flex gap-1 overflow-x-auto border-b border-line px-2 py-1.5 [scrollbar-width:none]">
108 + {CHANNELS.map((c) => (
109 + <button key={c.key} type="button" onClick={() => setTab(c.key)} className={`whitespace-nowrap rounded-md px-2 py-1 text-[12px] ${tab === c.key ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${c.key === "silent" ? "text-silent" : ""}`}>
110 + {c.label}
111 + </button>
112 + ))}
113 + </div>
114 + )}
115 + <div>
116 + {items.length === 0 && !loading && <Empty>No events in this channel yet. Sensors are being checked continuously — meaningful changes will appear here the moment they are detected.</Empty>}
117 + {items.map((ev) => (
118 + <EventRow key={ev.id} ev={ev} flash={fresh.has(ev.id)} now={now} />
119 + ))}
120 + </div>
121 + <footer className="flex items-center justify-between px-3 py-2 text-[12px] text-fg-subtle">
122 + <span>
123 + Showing {items.length}{items.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS})` : ""}
124 + </span>
125 + {cursor ? (
126 + <button type="button" disabled={loading} onClick={() => load(false, cursor)} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[12px] text-fg hover:border-line-strong disabled:opacity-50">
127 + {loading ? "Loading…" : "Load more"}
128 + </button>
129 + ) : (
130 + <Link href="/explore" className="hover:text-fg">Explore →</Link>
131 + )}
132 + </footer>
133 + </section>
134 + );
135 +}
added apps/web/src/components/nav.tsx +105 −0
@@ -0,0 +1,105 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { Activity, Bell, Compass, Eye, Search, User } from "lucide-react";
6 +import { useEffect, useRef, useState } from "react";
7 +import { ThemeToggle } from "./theme";
8 +
9 +const NAV = [
10 + ["/", "Live"],
11 + ["/breaking", "Breaking"],
12 + ["/explore", "Explore"],
13 + ["/sources", "Sources"],
14 + ["/entities", "Entities"],
15 + ["/silent", "Silent"],
16 + ["/watchlists", "Watchlists"],
17 + ["/alerts", "Alerts"],
18 + ["/api", "API"],
19 + ["/health", "Health"],
20 +] as const;
21 +
22 +export function Wordmark() {
23 + return (
24 + <Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
25 + <span className="relative inline-flex size-2.5 items-center justify-center">
26 + <span className="absolute inset-0 rounded-full bg-signal animate-pulse-dot" />
27 + </span>
28 + <span className="text-[15px]">
29 + Web<span className="text-signal">Sensor</span>
30 + </span>
31 + </Link>
32 + );
33 +}
34 +
35 +export function TopNav() {
36 + const path = usePathname();
37 + const router = useRouter();
38 + const [q, setQ] = useState("");
39 + const input = useRef<HTMLInputElement>(null);
40 + useEffect(() => {
41 + const onKey = (e: KeyboardEvent): void => {
42 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
43 + e.preventDefault();
44 + input.current?.focus();
45 + }
46 + };
47 + window.addEventListener("keydown", onKey);
48 + return () => window.removeEventListener("keydown", onKey);
49 + }, []);
50 + return (
51 + <header className="sticky top-0 z-40 border-b border-line bg-bg/90 backdrop-blur">
52 + <div className="mx-auto flex h-12 max-w-[1500px] items-center gap-4 px-3 sm:px-4">
53 + <Wordmark />
54 + <nav className="hidden items-center gap-0.5 lg:flex">
55 + {NAV.map(([href, label]) => {
56 + const active = href === "/" ? path === "/" : path.startsWith(href);
57 + return (
58 + <Link key={href} href={href} className={`rounded-md px-2 py-1 text-[12.5px] ${active ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"}`}>
59 + {label}
60 + </Link>
61 + );
62 + })}
63 + </nav>
64 + <form
65 + className="ml-auto flex items-center"
66 + onSubmit={(e) => {
67 + e.preventDefault();
68 + if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`);
69 + }}
70 + >
71 + <label className="relative block">
72 + <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" />
73 + <input ref={input} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search events, entities, URLs…" className="h-8 w-40 rounded-md border border-line bg-panel pl-7 pr-9 text-[12.5px] placeholder:text-fg-subtle focus:w-64 sm:w-56 sm:focus:w-80 transition-[width]" />
74 + <kbd className="pointer-events-none absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded border border-line bg-panel-2 px-1 font-mono text-[10px] text-fg-subtle sm:block">⌘K</kbd>
75 + </label>
76 + </form>
77 + <ThemeToggle />
78 + </div>
79 + </header>
80 + );
81 +}
82 +
83 +export function MobileNav() {
84 + const path = usePathname();
85 + const items = [
86 + ["/", "Live", Activity],
87 + ["/explore", "Explore", Compass],
88 + ["/alerts", "Alerts", Bell],
89 + ["/watchlists", "Watchlist", Eye],
90 + ["/watchlists", "Profile", User],
91 + ] as const;
92 + return (
93 + <nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 border-t border-line bg-bg/95 backdrop-blur lg:hidden" style={{ paddingBottom: "env(safe-area-inset-bottom)" }}>
94 + {items.map(([href, label, Icon], i) => {
95 + const active = href === "/" ? path === "/" : path.startsWith(href) && !(i === 4 && label === "Profile" && path.startsWith("/watchlists") && false);
96 + return (
97 + <Link key={label} href={href} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${active && !(i === 4) ? "text-signal" : "text-fg-muted"}`}>
98 + <Icon className="size-4" />
99 + {label}
100 + </Link>
101 + );
102 + })}
103 + </nav>
104 + );
105 +}
added apps/web/src/components/rail.tsx +54 −0
@@ -0,0 +1,54 @@
1 +import Link from "next/link";
2 +import type { Cluster, TrendingItem } from "@/lib/api";
3 +import { fmtScore, relTime } from "@/lib/format";
4 +import { Empty, Panel, Score } from "./ui";
5 +
6 +export function TrendingPanel({ items }: { items: TrendingItem[] }) {
7 + return (
8 + <Panel title="Trending now" dense action={<Link href="/entities" className="text-[11px] text-fg-subtle hover:text-fg">all entities →</Link>}>
9 + {items.length === 0 ? (
10 + <Empty>Trending is computed from the last 24 h of events.</Empty>
11 + ) : (
12 + <ol className="divide-y divide-line">
13 + {items.map((t, i) => (
14 + <li key={t.id} className="grid grid-cols-[1.5rem_1fr_auto] items-center gap-2 px-3 py-1.5 text-[13px]">
15 + <span className="font-mono text-[12px] text-fg-subtle tabular">{i + 1}</span>
16 + <div className="min-w-0">
17 + <Link href={`/company/${t.id}`} className="block truncate font-medium hover:underline">{t.name}</Link>
18 + <div className="truncate text-[11px] text-fg-subtle">
19 + {t.events} events · {t.sources} source{t.sources === 1 ? "" : "s"}{t.silent ? ` · ${t.silent} silent` : ""}
20 + </div>
21 + </div>
22 + <span className={`font-mono text-[12px] font-semibold tabular ${t.events > t.prev_events ? "text-signal" : "text-fg-muted"}`}>
23 + {t.events > t.prev_events ? "↑" : t.events < t.prev_events ? "↓" : "→"} {fmtScore(t.score)}
24 + </span>
25 + </li>
26 + ))}
27 + </ol>
28 + )}
29 + </Panel>
30 + );
31 +}
32 +
33 +export function ClustersPanel({ items }: { items: Cluster[] }) {
34 + const multi = items.filter((c) => c.event_count > 1).slice(0, 8);
35 + return (
36 + <Panel title="Event clusters" dense>
37 + {multi.length === 0 ? (
38 + <Empty>Related observations are grouped into clusters as they arrive.</Empty>
39 + ) : (
40 + <ul className="divide-y divide-line">
41 + {multi.map((c) => (
42 + <li key={c.id} className="flex items-start gap-2 px-3 py-1.5 text-[13px]">
43 + <Score value={c.max_importance} size="sm" />
44 + <div className="min-w-0">
45 + <Link href={c.events?.[0]?.slug ? `/event/${c.events[0].slug}` : "/explore"} className="line-clamp-2 font-medium hover:underline">{c.title}</Link>
46 + <div className="text-[11px] text-fg-subtle">{c.event_count} observations · {relTime(c.last_at)}</div>
47 + </div>
48 + </li>
49 + ))}
50 + </ul>
51 + )}
52 + </Panel>
53 + );
54 +}
added apps/web/src/components/stats-strip.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import type { Stats } from "@/lib/api";
2 +import { fmtInt, relTime } from "@/lib/format";
3 +import { Stat } from "./ui";
4 +
5 +export function StatsStrip({ stats }: { stats: Stats }) {
6 + const items: { label: string; value: string; hint?: string }[] = [
7 + { label: "Sources monitored", value: fmtInt(stats.sources), hint: `${fmtInt(stats.entities)} entities` },
8 + { label: "Sensors", value: fmtInt(stats.sensors), hint: stats.sensors_degraded ? `${fmtInt(stats.sensors_degraded)} degraded` : "all healthy" },
9 + { label: "Checks today", value: fmtInt(stats.checks_today), hint: stats.checks_today ? `${Math.round(((stats.not_modified_today ?? 0) / Math.max(1, stats.checks_today ?? 1)) * 100)}% served 304` : undefined },
10 + { label: "Changes 24h", value: fmtInt(stats.changes_24h), hint: `${fmtInt(stats.snapshots)} snapshots preserved` },
11 + { label: "Meaningful events 24h", value: fmtInt(stats.events_24h), hint: stats.last_event_at ? `last ${relTime(stats.last_event_at)}` : undefined },
12 + { label: "Silent changes 24h", value: fmtInt(stats.silent_24h), hint: `${fmtInt(stats.breaking_24h)} breaking` },
13 + ];
14 + return (
15 + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0">
16 + {items.map((s) => (
17 + <Stat key={s.label} label={s.label} value={s.value} hint={s.hint} />
18 + ))}
19 + </div>
20 + );
21 +}
added apps/web/src/components/theme.tsx +30 −0
@@ -0,0 +1,30 @@
1 +"use client";
2 +
3 +import { ThemeProvider, useTheme } from "next-themes";
4 +import { Moon, Sun } from "lucide-react";
5 +import { useSyncExternalStore, type ReactNode } from "react";
6 +
7 +const noop = (): (() => void) => () => {};
8 +/** true after hydration, false during SSR — without a setState-in-effect. */
9 +export function useMounted(): boolean {
10 + return useSyncExternalStore(noop, () => true, () => false);
11 +}
12 +
13 +export function Providers({ children }: { children: ReactNode }) {
14 + return (
15 + <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
16 + {children}
17 + </ThemeProvider>
18 + );
19 +}
20 +
21 +export function ThemeToggle() {
22 + const { resolvedTheme, setTheme } = useTheme();
23 + const mounted = useMounted();
24 + const dark = mounted ? resolvedTheme === "dark" : true;
25 + return (
26 + <button type="button" aria-label="Toggle theme" onClick={() => setTheme(dark ? "light" : "dark")} className="inline-flex size-8 items-center justify-center rounded-md border border-line bg-panel text-fg-muted hover:text-fg">
27 + {dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
28 + </button>
29 + );
30 +}
added apps/web/src/components/timeline.tsx +38 −0
@@ -0,0 +1,38 @@
1 +import Link from "next/link";
2 +import type { EventItem } from "@/lib/api";
3 +import { dayHeader, utcDate, utcTime, typeLabel } from "@/lib/format";
4 +import { Empty, Score, SilentBadge } from "./ui";
5 +
6 +/** Git-log style timeline grouped by UTC day (spec §21). */
7 +export function Timeline({ events, showSource = false }: { events: EventItem[]; showSource?: boolean }) {
8 + if (!events.length) return <Empty>No events recorded yet.</Empty>;
9 + const groups = new Map<string, EventItem[]>();
10 + for (const e of events) {
11 + const d = utcDate(e.detected_at);
12 + if (!groups.has(d)) groups.set(d, []);
13 + groups.get(d)!.push(e);
14 + }
15 + return (
16 + <div>
17 + {[...groups.entries()].map(([day, evs]) => (
18 + <div key={day}>
19 + <div className="sticky top-12 z-10 border-y border-line bg-panel-2/95 px-3 py-1 font-mono text-[11px] font-semibold tracking-wider text-fg-muted backdrop-blur">{dayHeader(evs[0]!.detected_at)}</div>
20 + {evs.map((e) => (
21 + <div key={e.id} className="grid grid-cols-[3.2rem_1fr_auto] items-start gap-x-3 px-3 py-1.5 hairline text-[13px]">
22 + <span className="font-mono text-[12px] text-fg-subtle tabular">{utcTime(e.detected_at, false)}</span>
23 + <div className="min-w-0">
24 + <div className="flex flex-wrap items-center gap-x-2 text-[11px] text-fg-subtle">
25 + {showSource && <span className="font-mono uppercase text-fg-muted">{e.source?.name}</span>}
26 + <span>{typeLabel(e.event_type)}</span>
27 + {e.silent_change && <SilentBadge compact />}
28 + </div>
29 + <Link href={`/event/${e.slug}`} className="block truncate font-medium hover:underline">{e.title}</Link>
30 + </div>
31 + <Score value={e.importance} size="sm" />
32 + </div>
33 + ))}
34 + </div>
35 + ))}
36 + </div>
37 + );
38 +}
added apps/web/src/components/ui.tsx +167 −0
@@ -0,0 +1,167 @@
1 +import Link from "next/link";
2 +import type { ReactNode } from "react";
3 +import { fmtScore, importanceBand, typeLabel } from "@/lib/format";
4 +
5 +export function Score({ value, size = "md", title }: { value: number | null | undefined; size?: "sm" | "md" | "lg"; title?: string }) {
6 + const v = value ?? 0;
7 + const band = importanceBand(v);
8 + const color = band === "hot" ? "bg-hot/15 text-hot border-hot/40" : band === "high" ? "bg-high/15 text-high border-high/40" : band === "mid" ? "bg-mid/15 text-mid border-mid/40" : "bg-panel-2 text-fg-muted border-line";
9 + const sz = size === "sm" ? "min-w-7 px-1 text-[11px] h-5" : size === "lg" ? "min-w-14 px-2 text-xl h-9" : "min-w-9 px-1.5 text-xs h-6";
10 + return (
11 + <span title={title ?? `Importance ${fmtScore(v)}`} className={`inline-flex items-center justify-center rounded-sm border font-mono font-semibold tabular ${color} ${sz}`}>
12 + {fmtScore(v)}
13 + </span>
14 + );
15 +}
16 +
17 +export function Chip({ children, tone = "default", href, className = "" }: { children: ReactNode; tone?: "default" | "silent" | "signal" | "danger" | "warn" | "info" | "ok"; href?: string; className?: string }) {
18 + const tones: Record<string, string> = {
19 + default: "border-line bg-panel-2 text-fg-muted",
20 + silent: "border-silent/40 bg-silent-soft text-silent",
21 + signal: "border-signal/40 bg-signal-soft text-signal",
22 + danger: "border-danger/40 bg-danger/10 text-danger",
23 + warn: "border-warn/40 bg-warn/10 text-warn",
24 + info: "border-info/40 bg-info/10 text-info",
25 + ok: "border-ok/40 bg-ok/10 text-ok",
26 + };
27 + const cls = `inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[10.5px] font-medium leading-4 whitespace-nowrap ${tones[tone]} ${className}`;
28 + if (href) return <Link href={href} className={`${cls} hover:border-line-strong`}>{children}</Link>;
29 + return <span className={cls}>{children}</span>;
30 +}
31 +
32 +export function TypeChip({ type, href }: { type: string; href?: string }) {
33 + return <Chip href={href}>{typeLabel(type)}</Chip>;
34 +}
35 +
36 +export function SilentBadge({ compact = false }: { compact?: boolean }) {
37 + return (
38 + <Chip tone="silent" className="font-semibold tracking-wide">
39 + <span aria-hidden>⚠</span> {compact ? "SILENT" : "SILENT CHANGE"}
40 + </Chip>
41 + );
42 +}
43 +
44 +export function EvidenceTag({ label }: { label: string | null | undefined }) {
45 + const l = (label ?? "OBSERVED").toUpperCase();
46 + const tone = l === "CONFIRMED" ? "ok" : l === "INFERRED" ? "info" : l === "UNCONFIRMED" ? "warn" : "default";
47 + return <Chip tone={tone as "ok" | "info" | "warn" | "default"} className="font-mono tracking-wider">{l}</Chip>;
48 +}
49 +
50 +export function HealthPill({ health }: { health: string | null | undefined }) {
51 + const h = (health ?? "UP").toUpperCase();
52 + const tone = h === "UP" ? "ok" : h === "DEGRADED" || h === "RATE_LIMITED" ? "warn" : h === "ERROR" ? "danger" : "default";
53 + return (
54 + <Chip tone={tone as "ok" | "warn" | "danger" | "default"} className="font-mono">
55 + <span className={`inline-block size-1.5 rounded-full ${h === "UP" ? "bg-ok" : h === "ERROR" ? "bg-danger" : h === "DISABLED" ? "bg-low" : "bg-warn"}`} /> {h}
56 + </Chip>
57 + );
58 +}
59 +
60 +export function TierBadge({ tier }: { tier: string | null | undefined }) {
61 + return <span className="inline-flex size-5 items-center justify-center rounded-sm border border-line bg-panel-2 font-mono text-[11px] font-semibold text-fg-muted" title={`Tier ${tier}`}>{tier ?? "?"}</span>;
62 +}
63 +
64 +export function Panel({ title, action, children, className = "", dense = false }: { title?: ReactNode; action?: ReactNode; children: ReactNode; className?: string; dense?: boolean }) {
65 + return (
66 + <section className={`panel ${className}`}>
67 + {title !== undefined && (
68 + <header className="flex items-center justify-between gap-3 border-b border-line px-3 py-2">
69 + <h2 className="label">{title}</h2>
70 + {action}
71 + </header>
72 + )}
73 + <div className={dense ? "" : "p-3"}>{children}</div>
74 + </section>
75 + );
76 +}
77 +
78 +export function Empty({ children = "No data yet — the engine is warming up." }: { children?: ReactNode }) {
79 + return <div className="px-3 py-8 text-center text-[13px] text-fg-subtle">{children}</div>;
80 +}
81 +
82 +export function Stat({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) {
83 + return (
84 + <div className="flex min-w-0 flex-col gap-0.5 px-3 py-2">
85 + <span className="label">{label}</span>
86 + <span className="font-mono text-lg font-semibold leading-tight tabular">{value}</span>
87 + {hint && <span className="truncate text-[11px] text-fg-subtle">{hint}</span>}
88 + </div>
89 + );
90 +}
91 +
92 +export function Bar({ value, max = 100, tone = "signal" }: { value: number; max?: number; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) {
93 + const pct = Math.max(0, Math.min(100, (value / max) * 100));
94 + const c = tone === "hot" ? "bg-hot" : tone === "high" ? "bg-high" : tone === "mid" ? "bg-mid" : tone === "silent" ? "bg-silent" : tone === "info" ? "bg-info" : "bg-signal";
95 + return (
96 + <div className="h-1 w-full overflow-hidden rounded-full bg-panel-2">
97 + <div className={`h-full rounded-full ${c}`} style={{ width: `${pct}%` }} />
98 + </div>
99 + );
100 +}
101 +
102 +export function Gauge({ label, value, tone }: { label: string; value: number | null | undefined; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) {
103 + const v = value ?? 0;
104 + const band = importanceBand(v);
105 + const t = tone ?? (band === "hot" ? "hot" : band === "high" ? "high" : band === "mid" ? "mid" : "signal");
106 + return (
107 + <div className="flex flex-col gap-1">
108 + <div className="flex items-baseline justify-between">
109 + <span className="label">{label}</span>
110 + <span className="font-mono text-base font-semibold tabular">{fmtScore(v)}</span>
111 + </div>
112 + <Bar value={v} tone={t} />
113 + </div>
114 + );
115 +}
116 +
117 +export function PageHeader({ title, kicker, description, actions }: { title: ReactNode; kicker?: ReactNode; description?: ReactNode; actions?: ReactNode }) {
118 + return (
119 + <div className="mb-4 flex flex-wrap items-end justify-between gap-3">
120 + <div className="min-w-0">
121 + {kicker && <div className="label mb-1">{kicker}</div>}
122 + <h1 className="text-xl font-semibold leading-tight sm:text-2xl">{title}</h1>
123 + {description && <p className="mt-1 max-w-3xl text-[13px] text-fg-muted">{description}</p>}
124 + </div>
125 + {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
126 + </div>
127 + );
128 +}
129 +
130 +export function Mono({ children, className = "" }: { children: ReactNode; className?: string }) {
131 + return <span className={`font-mono tabular ${className}`}>{children}</span>;
132 +}
133 +
134 +export function Table({ head, children, className = "" }: { head: ReactNode[]; children: ReactNode; className?: string }) {
135 + return (
136 + <div className={`overflow-x-auto ${className}`}>
137 + <table className="w-full text-[12.5px]">
138 + <thead>
139 + <tr className="border-b border-line text-left">
140 + {head.map((h, i) => (
141 + <th key={i} className="label whitespace-nowrap px-3 py-2 font-semibold">
142 + {h}
143 + </th>
144 + ))}
145 + </tr>
146 + </thead>
147 + <tbody className="divide-y divide-line">{children}</tbody>
148 + </table>
149 + </div>
150 + );
151 +}
152 +
153 +export function Td({ children, className = "", mono = false, colSpan }: { children?: ReactNode; className?: string; mono?: boolean; colSpan?: number }) {
154 + return (
155 + <td colSpan={colSpan} className={`px-3 py-1.5 align-top ${mono ? "font-mono tabular text-[12px]" : ""} ${className}`}>
156 + {children}
157 + </td>
158 + );
159 +}
160 +
161 +export function ExtLink({ href, children, className = "" }: { href: string; children: ReactNode; className?: string }) {
162 + return (
163 + <a href={href} target="_blank" rel="noopener noreferrer nofollow" className={`text-info hover:underline ${className}`}>
164 + {children}
165 + </a>
166 + );
167 +}
added apps/web/src/components/watch-button.tsx +54 −0
@@ -0,0 +1,54 @@
1 +"use client";
2 +
3 +import { Check, Eye, Link2 } from "lucide-react";
4 +import { useState } from "react";
5 +import type { Watchlist } from "@/lib/api";
6 +import { ownerFetch } from "@/lib/owner";
7 +
8 +/** Adds an entity/source to the user's default watchlist (created on demand). */
9 +export function WatchButton({ kind, value, label }: { kind: "entity" | "source" | "keyword" | "category"; value: string; label?: string }) {
10 + const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle");
11 + const add = async (): Promise<void> => {
12 + setState("busy");
13 + try {
14 + const { items } = await ownerFetch<{ items: Watchlist[] }>("/api/v1/watchlists");
15 + let wl = items[0];
16 + if (!wl) wl = await ownerFetch<Watchlist>("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: "My watchlist", items: [] }) });
17 + const existing = (wl.items ?? []).map((i) => ({ kind: i.kind, value: i.value }));
18 + if (!existing.some((i) => i.kind === kind && i.value === value)) existing.push({ kind, value });
19 + await ownerFetch(`/api/v1/watchlists/${wl.id}`, { method: "PUT", body: JSON.stringify({ items: existing }) });
20 + setState("done");
21 + } catch {
22 + setState("error");
23 + }
24 + };
25 + return (
26 + <button type="button" onClick={add} disabled={state === "busy" || state === "done"} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong disabled:opacity-70">
27 + {state === "done" ? <Check className="size-3.5 text-signal" /> : <Eye className="size-3.5" />}
28 + {state === "done" ? "Watching" : state === "error" ? "Failed — retry" : label ?? "Watch"}
29 + </button>
30 + );
31 +}
32 +
33 +export function ShareButton({ path }: { path: string }) {
34 + const [done, setDone] = useState(false);
35 + return (
36 + <button
37 + type="button"
38 + onClick={async () => {
39 + const url = `${window.location.origin}${path}`;
40 + try {
41 + await navigator.clipboard.writeText(url);
42 + setDone(true);
43 + setTimeout(() => setDone(false), 1500);
44 + } catch {
45 + window.prompt("Copy link", url);
46 + }
47 + }}
48 + className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong"
49 + >
50 + {done ? <Check className="size-3.5 text-signal" /> : <Link2 className="size-3.5" />}
51 + {done ? "Copied" : "Share"}
52 + </button>
53 + );
54 +}
added apps/web/src/lib/api.ts +491 −0
@@ -0,0 +1,491 @@
1 +/**
2 + * Typed gateway client. Server Components call the gateway over loopback (`API_URL`);
3 + * client components use same-origin `/api/v1` (or `NEXT_PUBLIC_API_URL` in dev).
4 + * Every server helper swallows failures and returns an empty shape so pages render
5 + * "warming up" states instead of crashing.
6 + */
7 +
8 +export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.websensor.io";
9 +
10 +export function serverApiBase(): string {
11 + return process.env.API_URL ?? "http://127.0.0.1:8260";
12 +}
13 +
14 +export function clientApiBase(): string {
15 + if (typeof window === "undefined") return serverApiBase();
16 + return process.env.NEXT_PUBLIC_API_URL ?? "";
17 +}
18 +
19 +// ---------------------------------------------------------------------------------------
20 +// Types (snake_case = REST rows; camelCase = WebSocket payloads)
21 +// ---------------------------------------------------------------------------------------
22 +
23 +export interface EntityRef {
24 + id: string;
25 + name: string;
26 + type: string;
27 + role?: string;
28 +}
29 +
30 +export interface SourceRef {
31 + id: string;
32 + name: string;
33 + domain: string;
34 + tier?: string;
35 + categories?: string[];
36 +}
37 +
38 +export interface SensorRef {
39 + id: string;
40 + name: string;
41 + type: string;
42 + connector?: string;
43 + tier?: string;
44 +}
45 +
46 +export interface EventItem {
47 + id: string;
48 + slug: string;
49 + event_type: string;
50 + title: string;
51 + summary: string;
52 + why_it_matters?: string | null;
53 + importance: number;
54 + confidence: number;
55 + novelty: number;
56 + categories: string[];
57 + keywords: string[];
58 + silent_change: boolean;
59 + evidence_label: string;
60 + url: string;
61 + detected_at: string;
62 + published_at?: string | null;
63 + observed_from?: string | null;
64 + processed_at?: string | null;
65 + published_to_feed_at?: string | null;
66 + detection_latency_ms?: number | null;
67 + processing_latency_ms?: number | null;
68 + cluster_id?: string | null;
69 + sensor_id: string;
70 + source_id: string;
71 + change_id?: string | null;
72 + old_snapshot_id?: string | null;
73 + new_snapshot_id?: string | null;
74 + importance_components?: Record<string, number> | null;
75 + processing_version?: string;
76 + interpretation?: Record<string, unknown> | null;
77 + source: SourceRef;
78 + sensor: SensorRef;
79 + entities: EntityRef[];
80 + cluster_size?: number | null;
81 +}
82 +
83 +export interface LiveEvent {
84 + id: string;
85 + slug: string;
86 + type: string;
87 + title: string;
88 + summary: string;
89 + importance: number;
90 + confidence: number;
91 + novelty: number;
92 + silent: boolean;
93 + evidence: string;
94 + source: SourceRef;
95 + sensor: SensorRef;
96 + entities: EntityRef[];
97 + categories: string[];
98 + url: string;
99 + clusterId?: string | null;
100 + detectedAt: string;
101 + publishedAt?: string | null;
102 +}
103 +
104 +/** Normalize a WebSocket payload to the REST row shape used by all list components. */
105 +export function liveToEvent(e: LiveEvent): EventItem {
106 + return {
107 + id: e.id,
108 + slug: e.slug,
109 + event_type: e.type,
110 + title: e.title,
111 + summary: e.summary,
112 + importance: e.importance,
113 + confidence: e.confidence,
114 + novelty: e.novelty,
115 + categories: e.categories ?? [],
116 + keywords: [],
117 + silent_change: Boolean(e.silent),
118 + evidence_label: e.evidence ?? "OBSERVED",
119 + url: e.url,
120 + detected_at: e.detectedAt,
121 + published_at: e.publishedAt ?? null,
122 + cluster_id: e.clusterId ?? null,
123 + sensor_id: e.sensor?.id ?? "",
124 + source_id: e.source?.id ?? "",
125 + source: e.source,
126 + sensor: e.sensor,
127 + entities: e.entities ?? [],
128 + cluster_size: 1,
129 + };
130 +}
131 +
132 +export interface EventsPage {
133 + items: EventItem[];
134 + nextCursor: string | null;
135 +}
136 +
137 +export interface Stats {
138 + sources?: number;
139 + sensors?: number;
140 + entities?: number;
141 + snapshots?: number;
142 + events_total?: number;
143 + events_24h?: number;
144 + silent_24h?: number;
145 + breaking_24h?: number;
146 + changes_24h?: number;
147 + checks_today?: number;
148 + not_modified_today?: number;
149 + bytes_today?: number;
150 + checks_last_hour?: number;
151 + sensors_up?: number;
152 + sensors_degraded?: number;
153 + last_check_at?: string | null;
154 + last_event_at?: string | null;
155 + p50_processing_ms?: number | null;
156 + p50_detection_ms?: number | null;
157 +}
158 +
159 +export interface TrendingItem {
160 + id: string;
161 + name: string;
162 + type: string;
163 + domain?: string | null;
164 + events: number;
165 + importance_sum: number;
166 + sources: number;
167 + silent: number;
168 + max_importance: number;
169 + prev_events: number;
170 + score: number;
171 +}
172 +
173 +export interface Cluster {
174 + id: string;
175 + title: string;
176 + summary?: string | null;
177 + primary_event_id?: string | null;
178 + entity_ids: string[];
179 + categories: string[];
180 + event_count: number;
181 + max_importance: number;
182 + first_at: string;
183 + last_at: string;
184 + source?: SourceRef | null;
185 + events?: { id: string; slug: string; title: string; importance: number; event_type: string; detected_at: string; source_id: string; url: string }[] | null;
186 +}
187 +
188 +export interface SourceRow {
189 + id: string;
190 + name: string;
191 + domain: string;
192 + homepage?: string | null;
193 + description?: string | null;
194 + categories: string[];
195 + tier: string;
196 + importance_weight?: number;
197 + enabled?: boolean;
198 + notes?: string | null;
199 + sensor_count?: number;
200 + event_count?: number;
201 + events_24h?: number;
202 + last_event_at?: string | null;
203 + last_check_at?: string | null;
204 + sensors_degraded?: number;
205 + robots_checked_at?: string | null;
206 +}
207 +
208 +export interface SensorRow {
209 + id: string;
210 + name: string;
211 + url: string;
212 + type: string;
213 + connector: string;
214 + tier: string;
215 + health: string;
216 + enabled: boolean;
217 + next_check_at?: string | null;
218 + last_check_at?: string | null;
219 + last_change_at?: string | null;
220 + last_event_at?: string | null;
221 + last_status?: number | null;
222 + last_error?: string | null;
223 + consecutive_errors?: number;
224 + total_runs?: number;
225 + total_not_modified?: number;
226 + raw_changes?: number;
227 + meaningful_changes?: number;
228 + avg_latency_ms?: number | null;
229 + base_interval_seconds?: number | null;
230 + has_etag?: boolean;
231 + has_last_modified?: boolean;
232 + source_id?: string;
233 + source_name?: string;
234 + domain?: string;
235 + config?: Record<string, unknown>;
236 +}
237 +
238 +export interface SourceDetail {
239 + source: SourceRow;
240 + sensors: SensorRow[];
241 + entities: { id: string; name: string; type: string; importance: number; event_count: number }[];
242 + activity: { changes_2h?: number; changes_14d?: number; events_24h?: number; events_14d?: number; baseline_changes_per_day?: number; activity_score?: number };
243 + discovery: { url: string; kind: string; evidence?: string | null; score?: { value?: number; itemCount?: number | null; title?: string | null } | null; status: string; found_at: string }[];
244 +}
245 +
246 +export interface SensorDetail {
247 + sensor: SensorRow;
248 + runs: { id: string; started_at: string; finished_at?: string | null; http_status?: number | null; outcome: string; error?: string | null; duration_ms?: number | null; bytes?: number | null; fetch_method?: string | null; snapshot_id?: string | null }[];
249 + snapshots: SnapshotRow[];
250 + changes: ChangeRow[];
251 +}
252 +
253 +export interface SnapshotRow {
254 + id: string;
255 + url?: string;
256 + captured_at: string;
257 + http_status?: number | null;
258 + content_type?: string | null;
259 + content_length?: number | null;
260 + content_hash?: string;
261 + canonical_hash?: string;
262 + etag?: string | null;
263 + last_modified?: string | null;
264 + title?: string | null;
265 + mode?: string;
266 + fetch_duration_ms?: number | null;
267 + extraction_confidence?: number | null;
268 + storage_key?: string | null;
269 +}
270 +
271 +export interface ChangeRow {
272 + id: string;
273 + detected_at: string;
274 + kind: string;
275 + signal: number;
276 + noise_ratio?: number;
277 + magnitude?: number;
278 + meaningful: boolean;
279 + event_id?: string | null;
280 + old_snapshot_id?: string | null;
281 + new_snapshot_id?: string | null;
282 + heuristic_type?: string | null;
283 + diff?: DiffSummary;
284 + heuristic?: Record<string, unknown>;
285 +}
286 +
287 +export interface DiffSummary {
288 + kind: "text" | "json" | "list";
289 + added?: unknown[];
290 + removed?: unknown[];
291 + modified?: unknown[];
292 + changes?: { path: string; op: string; before?: unknown; after?: unknown }[];
293 + stats?: { added: number; removed: number; modified: number; unchangedRatio: number };
294 + counts?: { added: number; removed: number; modified: number };
295 + truncated?: boolean;
296 +}
297 +
298 +export interface EventDetail {
299 + event: EventItem;
300 + related: EventItem[];
301 + cluster: Cluster | null;
302 + change: (ChangeRow & { diff: DiffSummary }) | null;
303 + interpretations: { version: number; model: string; created_at: string }[];
304 + snapshots: SnapshotRow[];
305 + sensor_reliability: { health?: string; success_rate?: number | null; avg_latency_ms?: number | null; total_runs?: number; raw_changes?: number; meaningful_changes?: number; last_check_at?: string | null } | null;
306 +}
307 +
308 +export interface EntityRow {
309 + id: string;
310 + name: string;
311 + type: string;
312 + description?: string | null;
313 + domain?: string | null;
314 + homepage?: string | null;
315 + importance: number;
316 + categories: string[];
317 + parent_id?: string | null;
318 + event_count: number;
319 + last_event_at?: string | null;
320 + events_24h?: number;
321 +}
322 +
323 +export interface EntityDetail {
324 + entity: EntityRow;
325 + children: EntityRow[];
326 + relations: { relation: string; from_id: string; to_id: string; from_name: string; to_name: string; to_type: string }[];
327 + sources: SourceRef[];
328 + aliases: string[];
329 + recent: EventItem[];
330 + by_type: { event_type: string; n: number }[];
331 +}
332 +
333 +export interface Explore {
334 + most_active_sources: { id: string; name: string; domain: string; events_24h: number; max_importance: number }[];
335 + biggest_changes: EventItem[];
336 + silent_changes: EventItem[];
337 + clusters: Cluster[];
338 + unusual_activity: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number }[];
339 + by_type: { event_type: string; n: number }[];
340 + by_category: { category: string; n: number }[];
341 + channels: Record<string, string[]>;
342 + event_types: Record<string, string>;
343 +}
344 +
345 +export interface ConnectorHealthRow {
346 + connector: string;
347 + status: string;
348 + runs_24h: number;
349 + errors_24h: number;
350 + success_rate?: number | null;
351 + avg_latency_ms?: number | null;
352 + changes_24h: number;
353 + events_24h: number;
354 + last_success_at?: string | null;
355 + last_error_at?: string | null;
356 + last_error?: string | null;
357 + http_codes: Record<string, number>;
358 + rate_limit_until?: string | null;
359 + updated_at: string;
360 +}
361 +
362 +export interface HealthReport {
363 + connectors: ConnectorHealthRow[];
364 + sensors_by_health: { health: string; n: number }[];
365 + degraded_sensors: SensorRow[];
366 + noisy_sensors: { id: string; name: string; source_id: string; url: string; raw_changes: number; meaningful_changes: number; noise_ratio: number | null }[];
367 + daily: Record<string, number | string>[];
368 + live: { clients: number; published: number };
369 +}
370 +
371 +export interface SearchResult {
372 + query: string;
373 + events: EventItem[];
374 + entities: EntityRow[];
375 + sources: SourceRow[];
376 + urls: { url: string; domain: string; status: string; change_count: number; last_seen_at: string }[];
377 +}
378 +
379 +export interface DomainTimeline {
380 + domain: string;
381 + source_id: string | null;
382 + urls: { url: string; status: string; first_seen_at: string; last_seen_at: string; snapshot_count: number; change_count: number; sensor_id?: string | null }[];
383 + events: EventItem[];
384 + nextCursor: string | null;
385 +}
386 +
387 +export interface UrlHistory {
388 + url: { url: string; domain?: string; status?: string; first_seen_at?: string; last_seen_at?: string; snapshot_count?: number; change_count?: number; sensor_id?: string | null };
389 + history: { id: number; at: string; kind: string; snapshot_id?: string | null; change_id?: string | null; event_id?: string | null; note?: string | null; event_title?: string | null; importance?: number | null; event_type?: string | null; event_slug?: string | null; change_kind?: string | null; signal?: number | null }[];
390 + snapshots: SnapshotRow[];
391 +}
392 +
393 +export interface CompareResult {
394 + a: SnapshotRow;
395 + b: SnapshotRow;
396 + before: string;
397 + after: string;
398 + diff: { unified: string; stats: { added: number; removed: number; modified: number; unchangedRatio: number }; added: string[]; removed: string[]; modified: { before: string; after: string }[] };
399 +}
400 +
401 +export interface Watchlist {
402 + id: string;
403 + name: string;
404 + owner_token?: string;
405 + created_at: string;
406 + items: { kind: string; value: string; added_at?: string }[];
407 +}
408 +
409 +export interface AlertRule {
410 + importance_min?: number;
411 + event_types?: string[];
412 + entities?: string[];
413 + sources?: string[];
414 + keywords?: string[];
415 + silent_only?: boolean;
416 + categories?: string[];
417 +}
418 +
419 +export interface Alert {
420 + id: string;
421 + name: string;
422 + rule: AlertRule;
423 + channel: string;
424 + enabled: boolean;
425 + created_at: string;
426 + last_fired_at?: string | null;
427 +}
428 +
429 +// ---------------------------------------------------------------------------------------
430 +// Server-side fetch helpers (never throw)
431 +// ---------------------------------------------------------------------------------------
432 +
433 +async function getJson<T>(path: string, fallback: T, init?: RequestInit & { revalidate?: number }): Promise<T> {
434 + try {
435 + const url = `${serverApiBase()}${path}`;
436 + const res = await fetch(url, { ...(init?.revalidate ? { next: { revalidate: init.revalidate } } : { cache: "no-store" }), headers: { accept: "application/json" }, signal: AbortSignal.timeout(8000) });
437 + if (!res.ok) return fallback;
438 + return (await res.json()) as T;
439 + } catch {
440 + return fallback;
441 + }
442 +}
443 +
444 +export type EventQuery = Partial<{
445 + after: string;
446 + before: string;
447 + category: string;
448 + entity: string;
449 + source: string;
450 + domain: string;
451 + sensor: string;
452 + cluster: string;
453 + importance_min: number;
454 + confidence_min: number;
455 + event_type: string;
456 + silent_change: boolean;
457 + q: string;
458 + limit: number;
459 + cursor: string;
460 + order: "recent" | "importance";
461 +}>;
462 +
463 +export function eventQueryString(q: EventQuery): string {
464 + const p = new URLSearchParams();
465 + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "") p.set(k, String(v));
466 + const s = p.toString();
467 + return s ? `?${s}` : "";
468 +}
469 +
470 +export const api = {
471 + events: (q: EventQuery = {}) => getJson<EventsPage>(`/api/v1/events${eventQueryString(q)}`, { items: [], nextCursor: null }),
472 + /** Importance ≥ 80 over the last 48 h, ranked. */
473 + breaking: (limit = 60) => getJson<EventsPage>(`/api/v1/events${eventQueryString({ importance_min: 80, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString(), limit })}`, { items: [], nextCursor: null }),
474 + event: (idOrSlug: string) => getJson<EventDetail | null>(`/api/v1/events/${encodeURIComponent(idOrSlug)}`, null),
475 + stats: () => getJson<Stats>("/api/v1/stats", {}, { revalidate: 5 }),
476 + trending: (hours = 24, limit = 10) => getJson<{ items: TrendingItem[] }>(`/api/v1/trending?hours=${hours}&limit=${limit}`, { items: [] }),
477 + explore: () => getJson<Explore | null>("/api/v1/explore", null),
478 + clusters: (limit = 20, since = 72) => getJson<{ items: Cluster[] }>(`/api/v1/clusters?limit=${limit}&since=${since}`, { items: [] }),
479 + sources: (q: { category?: string; q?: string } = {}) => getJson<{ items: SourceRow[] }>(`/api/v1/sources${eventQueryString(q)}`, { items: [] }),
480 + source: (id: string) => getJson<SourceDetail | null>(`/api/v1/sources/${encodeURIComponent(id)}`, null),
481 + sensor: (id: string) => getJson<SensorDetail | null>(`/api/v1/sensors/${encodeURIComponent(id)}`, null),
482 + entities: (q: { type?: string; q?: string; limit?: number } = {}) => getJson<{ items: EntityRow[] }>(`/api/v1/entities${eventQueryString(q)}`, { items: [] }),
483 + entity: (id: string) => getJson<EntityDetail | null>(`/api/v1/entities/${encodeURIComponent(id)}`, null),
484 + entityTimeline: (id: string, cursor?: string, limit = 100) => getJson<EventsPage>(`/api/v1/entities/${encodeURIComponent(id)}/timeline${eventQueryString({ cursor, limit })}`, { items: [], nextCursor: null }),
485 + domain: (domain: string, cursor?: string) => getJson<DomainTimeline | null>(`/api/v1/domains/${encodeURIComponent(domain)}/timeline${eventQueryString({ cursor })}`, null),
486 + urlHistory: (url: string) => getJson<UrlHistory | null>(`/api/v1/urls/history?url=${encodeURIComponent(url)}`, null),
487 + compare: (a: string, b: string) => getJson<CompareResult | null>(`/api/v1/snapshots/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`, null),
488 + change: (id: string) => getJson<{ change: ChangeRow & { diff: DiffSummary; sensor_url?: string; sensor_name?: string; source_id?: string }; unified: string | null } | null>(`/api/v1/changes/${encodeURIComponent(id)}`, null),
489 + health: () => getJson<HealthReport | null>("/api/v1/health/connectors", null),
490 + search: (q: string) => getJson<SearchResult>(`/api/v1/search?q=${encodeURIComponent(q)}`, { query: q, events: [], entities: [], sources: [], urls: [] }),
491 +};
added apps/web/src/lib/format.ts +143 −0
@@ -0,0 +1,143 @@
1 +export function fmtInt(n: number | string | null | undefined): string {
2 + const v = typeof n === "string" ? Number(n) : n;
3 + if (v === null || v === undefined || Number.isNaN(v)) return "—";
4 + return new Intl.NumberFormat("en-US").format(Math.round(v));
5 +}
6 +
7 +export function fmtCompact(n: number | string | null | undefined): string {
8 + const v = typeof n === "string" ? Number(n) : n;
9 + if (v === null || v === undefined || Number.isNaN(v)) return "—";
10 + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(v);
11 +}
12 +
13 +export function fmtBytes(n: number | string | null | undefined): string {
14 + const v = typeof n === "string" ? Number(n) : n;
15 + if (v === null || v === undefined || Number.isNaN(v)) return "—";
16 + const units = ["B", "KB", "MB", "GB", "TB"];
17 + let i = 0;
18 + let x = v;
19 + while (x >= 1024 && i < units.length - 1) {
20 + x /= 1024;
21 + i++;
22 + }
23 + return `${x.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
24 +}
25 +
26 +export function fmtScore(n: number | null | undefined): string {
27 + if (n === null || n === undefined || Number.isNaN(n)) return "—";
28 + return n >= 99.95 ? "100" : n.toFixed(n >= 10 ? 0 : 1);
29 +}
30 +
31 +export function fmtPct(n: number | null | undefined, digits = 0): string {
32 + if (n === null || n === undefined || Number.isNaN(n)) return "—";
33 + return `${(n * 100).toFixed(digits)}%`;
34 +}
35 +
36 +export function fmtMs(ms: number | null | undefined): string {
37 + if (ms === null || ms === undefined || Number.isNaN(ms)) return "—";
38 + if (ms < 1000) return `${Math.round(ms)} ms`;
39 + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
40 + if (ms < 3_600_000) return `${Math.round(ms / 60_000)} min`;
41 + if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)} h`;
42 + return `${(ms / 86_400_000).toFixed(1)} d`;
43 +}
44 +
45 +export function fmtDuration(seconds: number | null | undefined): string {
46 + if (seconds === null || seconds === undefined) return "—";
47 + if (seconds < 60) return `${seconds}s`;
48 + if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
49 + if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
50 + return `${(seconds / 86400).toFixed(1)}d`;
51 +}
52 +
53 +const pad = (n: number): string => String(n).padStart(2, "0");
54 +
55 +export function utcTime(iso: string | Date | null | undefined, seconds = true): string {
56 + if (!iso) return "—";
57 + const d = typeof iso === "string" ? new Date(iso) : iso;
58 + if (Number.isNaN(d.getTime())) return "—";
59 + return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}${seconds ? ":" + pad(d.getUTCSeconds()) : ""}`;
60 +}
61 +
62 +export function utcDate(iso: string | Date | null | undefined): string {
63 + if (!iso) return "—";
64 + const d = typeof iso === "string" ? new Date(iso) : iso;
65 + if (Number.isNaN(d.getTime())) return "—";
66 + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
67 +}
68 +
69 +export function utcDateTime(iso: string | Date | null | undefined): string {
70 + if (!iso) return "—";
71 + return `${utcDate(iso)} ${utcTime(iso)} UTC`;
72 +}
73 +
74 +const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
75 +export function dayHeader(iso: string): string {
76 + const d = new Date(iso);
77 + return `${MONTHS[d.getUTCMonth()]} ${pad(d.getUTCDate())} · ${d.getUTCFullYear()}`;
78 +}
79 +
80 +export function relTime(iso: string | Date | null | undefined, now = Date.now()): string {
81 + if (!iso) return "";
82 + const t = (typeof iso === "string" ? new Date(iso) : iso).getTime();
83 + if (Number.isNaN(t)) return "";
84 + const s = Math.max(0, Math.round((now - t) / 1000));
85 + if (s < 5) return "just now";
86 + if (s < 60) return `${s}s ago`;
87 + const m = Math.round(s / 60);
88 + if (m < 60) return `${m}m ago`;
89 + const h = Math.round(m / 60);
90 + if (h < 48) return `${h}h ago`;
91 + const d = Math.round(h / 24);
92 + if (d < 30) return `${d}d ago`;
93 + return `${Math.round(d / 30)}mo ago`;
94 +}
95 +
96 +export function untilTime(iso: string | null | undefined, now = Date.now()): string {
97 + if (!iso) return "—";
98 + const t = new Date(iso).getTime();
99 + const s = Math.round((t - now) / 1000);
100 + if (s <= 0) return "due";
101 + if (s < 60) return `in ${s}s`;
102 + if (s < 3600) return `in ${Math.round(s / 60)}m`;
103 + return `in ${(s / 3600).toFixed(1)}h`;
104 +}
105 +
106 +export function importanceBand(score: number): "hot" | "high" | "mid" | "low" {
107 + if (score >= 90) return "hot";
108 + if (score >= 75) return "high";
109 + if (score >= 50) return "mid";
110 + return "low";
111 +}
112 +
113 +export function typeLabel(t: string): string {
114 + return t.replace(/_/g, " ");
115 +}
116 +
117 +export function hostOf(url: string): string {
118 + try {
119 + return new URL(url).hostname.replace(/^www\./, "");
120 + } catch {
121 + return url;
122 + }
123 +}
124 +
125 +export function shortHash(h: string | null | undefined, n = 12): string {
126 + return h ? h.slice(0, n) : "—";
127 +}
128 +
129 +export const CHANNELS: { key: string; label: string; query: Record<string, string | number | boolean>; ws: string }[] = [
130 + { key: "all", label: "Everything", query: {}, ws: "events:global" },
131 + { key: "breaking", label: "Breaking", query: { importance_min: 80 }, ws: "events:breaking" },
132 + { key: "ai", label: "AI", query: { category: "ai" }, ws: "events:ai" },
133 + { key: "cyber", label: "Cyber", query: { category: "cyber" }, ws: "events:cyber" },
134 + { key: "finance", label: "Markets", query: { category: "finance" }, ws: "events:finance" },
135 + { key: "health", label: "Healthcare", query: { category: "health" }, ws: "events:health" },
136 + { key: "government", label: "Government", query: { category: "government" }, ws: "events:government" },
137 + { key: "science", label: "Science", query: { category: "science" }, ws: "events:science" },
138 + { key: "products", label: "Products", query: { category: "products" }, ws: "events:products" },
139 + { key: "infrastructure", label: "Infrastructure", query: { category: "infrastructure" }, ws: "events:infrastructure" },
140 + { key: "silent", label: "Silent", query: { silent_change: true }, ws: "events:silent" },
141 +];
142 +
143 +export const CHANNEL_KEYS = ["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure"] as const;
added apps/web/src/lib/owner.ts +37 −0
@@ -0,0 +1,37 @@
1 +"use client";
2 +
3 +import { clientApiBase } from "./api";
4 +
5 +const KEY = "ws_owner";
6 +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
7 +
8 +/** Anonymous owner token for watchlists/alerts (phase 1, no accounts). Generated once per browser. */
9 +export function ownerToken(): string {
10 + if (typeof window === "undefined") return "";
11 + let t = window.localStorage.getItem(KEY);
12 + if (!t || !/^[A-Za-z0-9_-]{16,80}$/.test(t)) {
13 + const bytes = new Uint8Array(32);
14 + window.crypto.getRandomValues(bytes);
15 + t = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join("");
16 + window.localStorage.setItem(KEY, t);
17 + }
18 + return t;
19 +}
20 +
21 +export async function ownerFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
22 + const res = await fetch(`${clientApiBase()}${path}`, {
23 + ...init,
24 + headers: { "content-type": "application/json", accept: "application/json", "x-websensor-owner": ownerToken(), ...(init.headers ?? {}) },
25 + });
26 + if (!res.ok) {
27 + const body = (await res.json().catch(() => ({}))) as { error?: string };
28 + throw new Error(body.error ?? `HTTP ${res.status}`);
29 + }
30 + return (await res.json()) as T;
31 +}
32 +
33 +export async function publicFetch<T>(path: string): Promise<T> {
34 + const res = await fetch(`${clientApiBase()}${path}`, { headers: { accept: "application/json" } });
35 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
36 + return (await res.json()) as T;
37 +}
added apps/web/src/lib/use-live.ts +80 −0
@@ -0,0 +1,80 @@
1 +"use client";
2 +
3 +import { useEffect, useRef, useState } from "react";
4 +import type { LiveEvent } from "./api";
5 +
6 +export type LiveStatus = "connecting" | "live" | "reconnecting" | "offline";
7 +
8 +export function wsUrl(): string {
9 + if (typeof window === "undefined") return "";
10 + const base = process.env.NEXT_PUBLIC_API_URL;
11 + if (base) return base.replace(/^http/, "ws") + "/api/v1/live";
12 + const proto = window.location.protocol === "https:" ? "wss" : "ws";
13 + return `${proto}://${window.location.host}/api/v1/live`;
14 +}
15 +
16 +/**
17 + * Shared WebSocket to /api/v1/live. Subscribes to the given channels and calls `onEvent`
18 + * for every matching event. Reconnects with exponential backoff (1 s → 30 s).
19 + */
20 +export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: string[]) => void): LiveStatus {
21 + const [status, setStatus] = useState<LiveStatus>("connecting");
22 + const handler = useRef(onEvent);
23 + useEffect(() => {
24 + handler.current = onEvent;
25 + });
26 + const key = channels.join("|");
27 +
28 + useEffect(() => {
29 + let ws: WebSocket | null = null;
30 + let closed = false;
31 + let attempt = 0;
32 + let timer: ReturnType<typeof setTimeout> | null = null;
33 + const subs = key.split("|").filter(Boolean);
34 +
35 + const connect = (): void => {
36 + if (closed) return;
37 + try {
38 + ws = new WebSocket(wsUrl());
39 + } catch {
40 + schedule();
41 + return;
42 + }
43 + ws.onopen = () => {
44 + attempt = 0;
45 + setStatus("live");
46 + ws?.send(JSON.stringify({ subscribe: subs, unsubscribe: subs.includes("events:global") ? [] : ["events:global"] }));
47 + };
48 + ws.onmessage = (m) => {
49 + try {
50 + const msg = JSON.parse(String(m.data)) as { type: string; event?: LiveEvent; channels?: string[] };
51 + if (msg.type === "event" && msg.event) handler.current(msg.event, msg.channels ?? []);
52 + } catch {
53 + // ignore malformed frames
54 + }
55 + };
56 + ws.onclose = () => {
57 + if (closed) return;
58 + setStatus(attempt > 3 ? "offline" : "reconnecting");
59 + schedule();
60 + };
61 + ws.onerror = () => {
62 + ws?.close();
63 + };
64 + };
65 + const schedule = (): void => {
66 + if (closed) return;
67 + const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
68 + attempt++;
69 + timer = setTimeout(connect, delay);
70 + };
71 + connect();
72 + return () => {
73 + closed = true;
74 + if (timer) clearTimeout(timer);
75 + ws?.close();
76 + };
77 + }, [key]);
78 +
79 + return status;
80 +}
added apps/web/tsconfig.json +21 −0
@@ -0,0 +1,21 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noEmit": true,
9 + "esModuleInterop": true,
10 + "module": "esnext",
11 + "moduleResolution": "bundler",
12 + "resolveJsonModule": true,
13 + "isolatedModules": true,
14 + "jsx": "react-jsx",
15 + "incremental": true,
16 + "plugins": [{ "name": "next" }],
17 + "paths": { "@/*": ["./src/*"] }
18 + },
19 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
20 + "exclude": ["node_modules"]
21 +}
added config/sources.yaml +2088 −0
@@ -0,0 +1,2088 @@
1 +# WebSensor — source registry seed (200 organizations).
2 +#
3 +# Each source becomes an organization entity. `sensors:` are curated endpoints (official APIs,
4 +# feeds, status pages) that are validated on the first run. `discover:` flags only ALLOW the
5 +# discovery engine to probe the domain (robots.txt sitemaps, well-known feed paths,
6 +# <link rel=alternate>, linked status pages, key pages); every candidate is fetched and parsed
7 +# before it becomes a sensor. Tiers: S 15–60 s · A 1–5 min · B 5–30 min · C 30 min–6 h · D 6–24 h
8 +# (base intervals in packages/core/src/taxonomy.ts, adaptive per sensor).
9 +#
10 +# Connector cheat-sheet: rss (RSS/Atom/JSON Feed) · sitemap · statuspage (Atlassian API v2 summary)
11 +# · github (releases/tags/commits Atom, advisories REST) · jsonlist (keyed records from a JSON API)
12 +# · http (HTML/JSON/text canonical diff).
13 +
14 +sources:
15 + # ───────────────────────── A · AI & Machine Learning ─────────────────────────
16 + - id: openai
17 + name: OpenAI
18 + domain: openai.com
19 + categories: [ai, technology]
20 + tier: S
21 + weight: 1.5
22 + aliases: [open ai]
23 + products:
24 + - { name: ChatGPT, type: product, aliases: [chat gpt] }
25 + - { name: GPT-5, type: AI_model, aliases: [gpt-5, gpt5] }
26 + - { name: GPT-4o, type: AI_model, aliases: [gpt-4o] }
27 + - { name: Codex, type: product }
28 + - { name: Sora, type: product }
29 + - { name: OpenAI API, type: API, aliases: [openai api, responses api] }
30 + discover: { rss: true, sitemap: true, status: true, pages: true }
31 + fallback: { firecrawl: true, scrapfly: true }
32 + sensors:
33 + - { name: status, url: "https://status.openai.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
34 + - { name: news feed, url: "https://openai.com/news/rss.xml", type: RSS, connector: rss, tier: S }
35 + - { name: API pricing, url: "https://openai.com/api/pricing/", type: HTML, connector: http, tier: C }
36 + - { name: sdk python releases, url: "https://github.com/openai/openai-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: openai/openai-python, kind: releases } }
37 + - id: anthropic
38 + name: Anthropic
39 + domain: anthropic.com
40 + homepage: https://www.anthropic.com
41 + categories: [ai, technology]
42 + tier: S
43 + weight: 1.5
44 + products:
45 + - { name: Claude, type: AI_model, aliases: [claude opus, claude sonnet, claude haiku, opus, sonnet] }
46 + - { name: Claude Code, type: product }
47 + - { name: Claude API, type: API, aliases: [anthropic api, messages api] }
48 + discover: { rss: true, sitemap: true, status: true, pages: true }
49 + fallback: { firecrawl: true, scrapfly: true }
50 + sensors:
51 + - { name: status, url: "https://status.anthropic.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
52 + - { name: sitemap, url: "https://www.anthropic.com/sitemap.xml", type: SITEMAP, connector: sitemap, tier: A, config: { maxUrls: 3000 } }
53 + - { name: news, url: "https://www.anthropic.com/news", type: HTML, connector: http, tier: S }
54 + - { name: pricing, url: "https://www.anthropic.com/pricing", type: HTML, connector: http, tier: A }
55 + - { name: sdk typescript releases, url: "https://github.com/anthropics/anthropic-sdk-typescript/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: anthropics/anthropic-sdk-typescript, kind: releases } }
56 + - { name: claude code releases, url: "https://github.com/anthropics/claude-code/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: anthropics/claude-code, kind: releases } }
57 + - id: deepmind
58 + name: Google DeepMind
59 + domain: deepmind.google
60 + categories: [ai, science]
61 + tier: A
62 + weight: 1.3
63 + aliases: [deepmind]
64 + products:
65 + - { name: Gemini, type: AI_model, aliases: [gemini pro, gemini flash, gemini ultra] }
66 + - { name: AlphaFold, type: product }
67 + discover: { rss: true, sitemap: true, pages: true }
68 + sensors:
69 + - { name: blog feed, url: "https://deepmind.google/blog/rss.xml", type: RSS, connector: rss, tier: A }
70 + - id: google-ai
71 + name: Google AI
72 + domain: ai.google
73 + categories: [ai, technology]
74 + tier: A
75 + discover: { rss: true, sitemap: true }
76 + - id: google-developers-ai
77 + name: Google Developers (AI)
78 + domain: developers.google.com
79 + categories: [ai, developer]
80 + tier: A
81 + aliases: [google ai studio, gemini api]
82 + products:
83 + - { name: Gemini API, type: API }
84 + discover: { rss: true, sitemap: false }
85 + sensors:
86 + - { name: gemini api changelog, url: "https://ai.google.dev/gemini-api/docs/changelog", type: HTML, connector: http, tier: A }
87 + - { name: developers blog feed, url: "https://developers.googleblog.com/feeds/posts/default", type: ATOM, connector: rss, tier: B }
88 + - id: microsoft-ai
89 + name: Microsoft AI
90 + domain: microsoft.com
91 + homepage: https://www.microsoft.com/ai
92 + categories: [ai, technology]
93 + tier: A
94 + aliases: [copilot]
95 + products:
96 + - { name: Microsoft Copilot, type: product, aliases: [copilot] }
97 + discover: { rss: true }
98 + sensors:
99 + - { name: microsoft blog feed, url: "https://blogs.microsoft.com/feed/", type: RSS, connector: rss, tier: A }
100 + - id: azure-ai
101 + name: Azure AI
102 + domain: azure.microsoft.com
103 + categories: [ai, cloud]
104 + tier: A
105 + products:
106 + - { name: Azure OpenAI Service, type: product, aliases: [azure openai] }
107 + - { name: Azure AI Foundry, type: product, aliases: [ai foundry] }
108 + discover: { rss: false }
109 + sensors:
110 + - { name: azure updates feed, url: "https://www.microsoft.com/releasecommunications/api/v2/azure/rss", type: RSS, connector: rss, tier: A }
111 + - id: meta-ai
112 + name: Meta AI
113 + domain: ai.meta.com
114 + categories: [ai, technology]
115 + tier: A
116 + aliases: [meta, facebook ai research, fair]
117 + products:
118 + - { name: Llama, type: AI_model, aliases: [llama 4, llama 3] }
119 + discover: { rss: true, sitemap: true, pages: true }
120 + sensors:
121 + - { name: blog, url: "https://ai.meta.com/blog/", type: HTML, connector: http, tier: A }
122 + - id: mistral
123 + name: Mistral AI
124 + domain: mistral.ai
125 + categories: [ai, technology]
126 + tier: A
127 + weight: 1.2
128 + aliases: [mistral]
129 + products:
130 + - { name: Le Chat, type: product }
131 + - { name: Mistral Large, type: AI_model }
132 + - { name: Codestral, type: AI_model }
133 + discover: { rss: true, sitemap: true, status: true, pages: true }
134 + sensors:
135 + - { name: news, url: "https://mistral.ai/news", type: HTML, connector: http, tier: A }
136 + - { name: docs changelog, url: "https://docs.mistral.ai/getting-started/changelog/", type: HTML, connector: http, tier: A }
137 + - id: huggingface
138 + name: Hugging Face
139 + domain: huggingface.co
140 + categories: [ai, developer]
141 + tier: A
142 + weight: 1.2
143 + aliases: [hugging face, hf]
144 + products:
145 + - { name: Transformers, type: software, aliases: [transformers library] }
146 + discover: { rss: true, status: true }
147 + sensors:
148 + - { name: blog feed, url: "https://huggingface.co/blog/feed.xml", type: RSS, connector: rss, tier: A }
149 + - { name: transformers releases, url: "https://github.com/huggingface/transformers/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: huggingface/transformers, kind: releases } }
150 + - id: cohere
151 + name: Cohere
152 + domain: cohere.com
153 + categories: [ai]
154 + tier: B
155 + products: [{ name: Command, type: AI_model, aliases: [command r, command a] }]
156 + discover: { rss: true, sitemap: true, status: true, pages: true }
157 + sensors:
158 + - { name: docs changelog, url: "https://docs.cohere.com/changelog", type: HTML, connector: http, tier: B }
159 + - id: xai
160 + name: xAI
161 + domain: x.ai
162 + categories: [ai]
163 + tier: A
164 + aliases: [x.ai]
165 + products: [{ name: Grok, type: AI_model, aliases: [grok 4, grok 3] }]
166 + discover: { rss: true, sitemap: true, status: true, pages: true }
167 + fallback: { scrapfly: true }
168 + sensors:
169 + - { name: news, url: "https://x.ai/news", type: HTML, connector: http, tier: C }
170 + - { name: api docs models, url: "https://docs.x.ai/docs/models", type: HTML, connector: http, tier: C }
171 + - id: stability-ai
172 + name: Stability AI
173 + domain: stability.ai
174 + categories: [ai]
175 + tier: B
176 + products: [{ name: Stable Diffusion, type: AI_model }]
177 + discover: { rss: true, sitemap: true, pages: true }
178 + - id: replicate
179 + name: Replicate
180 + domain: replicate.com
181 + categories: [ai, developer]
182 + tier: B
183 + discover: { rss: true, sitemap: true, status: true, pages: true }
184 + sensors:
185 + - { name: changelog, url: "https://replicate.com/changelog", type: HTML, connector: http, tier: B }
186 + - id: together-ai
187 + name: Together AI
188 + domain: together.ai
189 + categories: [ai, developer]
190 + tier: B
191 + aliases: [together]
192 + discover: { rss: true, sitemap: true, status: true, pages: true }
193 + sensors:
194 + - { name: pricing, url: "https://www.together.ai/pricing", type: HTML, connector: http, tier: B }
195 + - id: fireworks-ai
196 + name: Fireworks AI
197 + domain: fireworks.ai
198 + categories: [ai, developer]
199 + tier: B
200 + discover: { rss: true, sitemap: true, status: true, pages: true }
201 + - id: groq
202 + name: Groq
203 + domain: groq.com
204 + categories: [ai, semiconductors]
205 + tier: B
206 + discover: { rss: true, sitemap: true, status: true, pages: true }
207 + sensors:
208 + - { name: status, url: "https://groqstatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
209 + - id: cerebras
210 + name: Cerebras
211 + domain: cerebras.ai
212 + categories: [ai, semiconductors]
213 + tier: B
214 + discover: { rss: true, sitemap: true, pages: true }
215 + - id: perplexity
216 + name: Perplexity
217 + domain: perplexity.ai
218 + homepage: https://www.perplexity.ai
219 + categories: [ai]
220 + tier: B
221 + discover: { rss: true, sitemap: true, status: true }
222 + sensors:
223 + - { name: changelog, url: "https://docs.perplexity.ai/changelog/changelog", type: HTML, connector: http, tier: B }
224 + - id: nvidia
225 + name: NVIDIA
226 + domain: nvidia.com
227 + homepage: https://www.nvidia.com
228 + categories: [ai, semiconductors, technology]
229 + tier: A
230 + weight: 1.4
231 + products:
232 + - { name: CUDA, type: software }
233 + - { name: GeForce, type: product, aliases: [rtx] }
234 + - { name: Blackwell, type: technology }
235 + - { name: NIM, type: product, aliases: [nvidia nim] }
236 + discover: { rss: true, sitemap: false, status: false }
237 + sensors:
238 + - { name: developer blog feed, url: "https://developer.nvidia.com/blog/feed", type: RSS, connector: rss, tier: A }
239 + - { name: newsroom feed, url: "https://nvidianews.nvidia.com/releases.xml", type: RSS, connector: rss, tier: A }
240 + - { name: security bulletins, url: "https://www.nvidia.com/en-us/security/", type: HTML, connector: http, tier: A }
241 +
242 + # ───────────────────────── B · Cloud & Infrastructure ─────────────────────────
243 + - id: aws
244 + name: AWS
245 + domain: aws.amazon.com
246 + categories: [cloud, technology]
247 + tier: S
248 + weight: 1.4
249 + aliases: [amazon web services, amazon]
250 + products:
251 + - { name: Amazon EC2, type: product, aliases: [ec2] }
252 + - { name: Amazon S3, type: product, aliases: [s3] }
253 + - { name: Amazon Bedrock, type: product, aliases: [bedrock] }
254 + - { name: AWS Lambda, type: product, aliases: [lambda] }
255 + discover: { rss: false }
256 + sensors:
257 + - { name: whats new feed, url: "https://aws.amazon.com/about-aws/whats-new/recent/feed/", type: RSS, connector: rss, tier: S }
258 + - { name: security bulletins feed, url: "https://aws.amazon.com/security/security-bulletins/rss/feed/", type: RSS, connector: rss, tier: S }
259 + - { name: architecture blog feed, url: "https://aws.amazon.com/blogs/aws/feed/", type: RSS, connector: rss, tier: A }
260 + - id: azure
261 + name: Microsoft Azure
262 + domain: azure.microsoft.com
263 + homepage: https://azure.microsoft.com
264 + categories: [cloud, technology]
265 + tier: S
266 + weight: 1.3
267 + aliases: [azure]
268 + discover: { rss: false }
269 + sensors:
270 + - { name: azure status, url: "https://azure.status.microsoft/en-us/status/feed/", type: RSS, connector: rss, tier: S }
271 + - { name: azure blog feed, url: "https://azure.microsoft.com/en-us/blog/feed/", type: RSS, connector: rss, tier: A }
272 + - id: google-cloud
273 + name: Google Cloud
274 + domain: cloud.google.com
275 + categories: [cloud, technology]
276 + tier: S
277 + weight: 1.3
278 + aliases: [gcp, google cloud platform]
279 + products:
280 + - { name: Vertex AI, type: product }
281 + - { name: BigQuery, type: product }
282 + discover: { rss: false }
283 + sensors:
284 + - { name: release notes feed, url: "https://cloud.google.com/feeds/gcp-release-notes.xml", type: ATOM, connector: rss, tier: A }
285 + - name: incidents
286 + url: "https://status.cloud.google.com/incidents.json"
287 + type: REST_API
288 + connector: jsonlist
289 + tier: S
290 + config: { itemsPath: "", keyField: id, titleField: external_desc, urlField: uri, summaryField: most_recent_update.text, dateField: begin, compareFields: [end, most_recent_update.status], maxItems: 60, urlTemplate: "https://status.cloud.google.com/incidents/{key}" }
291 + - id: cloudflare
292 + name: Cloudflare
293 + domain: cloudflare.com
294 + homepage: https://www.cloudflare.com
295 + categories: [cloud, cyber, internet]
296 + tier: S
297 + weight: 1.3
298 + products:
299 + - { name: Cloudflare Workers, type: product, aliases: [workers] }
300 + - { name: Cloudflare Radar, type: product, aliases: [radar] }
301 + discover: { rss: false }
302 + sensors:
303 + - { name: blog feed, url: "https://blog.cloudflare.com/rss/", type: RSS, connector: rss, tier: A }
304 + - { name: status, url: "https://www.cloudflarestatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
305 + - { name: developers changelog feed, url: "https://developers.cloudflare.com/changelog/rss/index.xml", type: RSS, connector: rss, tier: A }
306 + - id: cloudflare-radar
307 + name: Cloudflare Radar
308 + domain: radar.cloudflare.com
309 + categories: [internet, cyber]
310 + tier: B
311 + discover: { rss: true }
312 + - id: digitalocean
313 + name: DigitalOcean
314 + domain: digitalocean.com
315 + homepage: https://www.digitalocean.com
316 + categories: [cloud]
317 + tier: B
318 + discover: { rss: true, sitemap: false }
319 + sensors:
320 + - { name: status, url: "https://status.digitalocean.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
321 + - { name: product changelog feed, url: "https://docs.digitalocean.com/release-notes/index.xml", type: RSS, connector: rss, tier: B }
322 + - id: akamai
323 + name: Akamai
324 + domain: akamai.com
325 + homepage: https://www.akamai.com
326 + categories: [cloud, cyber, internet]
327 + tier: B
328 + discover: { rss: true, sitemap: false, pages: true }
329 + sensors:
330 + - { name: security research feed, url: "https://feeds.feedburner.com/akamai/blog", type: RSS, connector: rss, tier: B }
331 + - id: fastly
332 + name: Fastly
333 + domain: fastly.com
334 + homepage: https://www.fastly.com
335 + categories: [cloud, internet]
336 + tier: B
337 + discover: { rss: true, status: true }
338 + - id: vercel
339 + name: Vercel
340 + domain: vercel.com
341 + categories: [cloud, developer]
342 + tier: A
343 + products: [{ name: Next.js, type: software, aliases: [nextjs, next.js] }]
344 + discover: { rss: false }
345 + sensors:
346 + - { name: changelog feed, url: "https://vercel.com/atom", type: ATOM, connector: rss, tier: A }
347 + - { name: status, url: "https://www.vercel-status.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
348 + - { name: nextjs releases, url: "https://github.com/vercel/next.js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: vercel/next.js, kind: releases } }
349 + - id: netlify
350 + name: Netlify
351 + domain: netlify.com
352 + homepage: https://www.netlify.com
353 + categories: [cloud, developer]
354 + tier: B
355 + discover: { rss: true, status: true }
356 + sensors:
357 + - { name: status, url: "https://www.netlifystatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
358 + - id: heroku
359 + name: Heroku
360 + domain: heroku.com
361 + homepage: https://www.heroku.com
362 + categories: [cloud, developer]
363 + tier: B
364 + discover: { rss: true }
365 + sensors:
366 + - { name: changelog feed, url: "https://devcenter.heroku.com/changelog/feed", type: ATOM, connector: rss, tier: B }
367 + - name: status
368 + url: "https://status.heroku.com/api/v4/current-status"
369 + type: JSON
370 + connector: http
371 + tier: S
372 + config: { ignoreKeys: [updated_at, created_at] }
373 + - id: fly-io
374 + name: Fly.io
375 + domain: fly.io
376 + categories: [cloud, developer]
377 + tier: B
378 + aliases: [fly.io, fly]
379 + discover: { rss: true, sitemap: false }
380 + sensors:
381 + - { name: status, url: "https://status.flyio.net/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
382 + - { name: blog feed, url: "https://fly.io/blog/feed.xml", type: RSS, connector: rss, tier: B }
383 + - id: render
384 + name: Render
385 + domain: render.com
386 + categories: [cloud, developer]
387 + tier: C
388 + discover: { rss: true, status: true }
389 + sensors:
390 + - { name: status, url: "https://status.render.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
391 + - { name: changelog, url: "https://render.com/changelog", type: HTML, connector: http, tier: B }
392 + - id: railway
393 + name: Railway
394 + domain: railway.com
395 + categories: [cloud, developer]
396 + tier: C
397 + discover: { rss: true, sitemap: true, status: true }
398 + sensors:
399 + - { name: changelog, url: "https://railway.com/changelog", type: HTML, connector: http, tier: B }
400 + - id: oracle-cloud
401 + name: Oracle Cloud
402 + domain: oracle.com
403 + homepage: https://www.oracle.com/cloud/
404 + categories: [cloud, enterprise]
405 + tier: C
406 + aliases: [oracle, oci]
407 + discover: { rss: true }
408 + sensors:
409 + - { name: oci release notes, url: "https://docs.oracle.com/en-us/iaas/releasenotes/feed", type: RSS, connector: rss, tier: B }
410 + - id: ibm-cloud
411 + name: IBM Cloud
412 + domain: ibm.com
413 + homepage: https://www.ibm.com/cloud
414 + categories: [cloud, ai, enterprise]
415 + tier: C
416 + aliases: [ibm, watsonx]
417 + discover: { rss: true }
418 + sensors:
419 + - { name: ibm newsroom feed, url: "https://newsroom.ibm.com/announcements?pagetemplate=rss", type: RSS, connector: rss, tier: B }
420 + - id: ovhcloud
421 + name: OVHcloud
422 + domain: ovhcloud.com
423 + homepage: https://www.ovhcloud.com
424 + categories: [cloud]
425 + tier: C
426 + aliases: [ovh]
427 + discover: { rss: true, status: true }
428 + - id: hetzner
429 + name: Hetzner
430 + domain: hetzner.com
431 + homepage: https://www.hetzner.com
432 + categories: [cloud]
433 + tier: C
434 + discover: { rss: true, sitemap: true, pages: true }
435 + sensors:
436 + - { name: cloud pricing, url: "https://www.hetzner.com/cloud/", type: HTML, connector: http, tier: B }
437 + - id: vultr
438 + name: Vultr
439 + domain: vultr.com
440 + homepage: https://www.vultr.com
441 + categories: [cloud]
442 + tier: C
443 + discover: { rss: true, status: true, pages: true }
444 + - id: linode
445 + name: Akamai Cloud (Linode)
446 + domain: linode.com
447 + homepage: https://www.linode.com
448 + categories: [cloud]
449 + tier: C
450 + aliases: [linode, akamai cloud]
451 + discover: { rss: true, status: true }
452 + sensors:
453 + - { name: status, url: "https://status.linode.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
454 +
455 + # ───────────────────────── C · Developer Platforms & Software ─────────────────────────
456 + - id: github
457 + name: GitHub
458 + domain: github.com
459 + categories: [developer, technology]
460 + tier: S
461 + weight: 1.3
462 + products: [{ name: GitHub Copilot, type: product }, { name: GitHub Actions, type: product }]
463 + discover: { rss: false }
464 + sensors:
465 + - { name: changelog feed, url: "https://github.blog/changelog/feed/", type: RSS, connector: rss, tier: S }
466 + - { name: status, url: "https://www.githubstatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
467 + - { name: security blog feed, url: "https://github.blog/security/feed/", type: RSS, connector: rss, tier: A }
468 + - id: gitlab
469 + name: GitLab
470 + domain: gitlab.com
471 + homepage: https://about.gitlab.com
472 + categories: [developer]
473 + tier: A
474 + discover: { rss: false }
475 + sensors:
476 + - { name: releases feed, url: "https://about.gitlab.com/atom.xml", type: ATOM, connector: rss, tier: A }
477 + - { name: security releases, url: "https://about.gitlab.com/releases/categories/releases/", type: HTML, connector: http, tier: A }
478 + - id: docker
479 + name: Docker
480 + domain: docker.com
481 + homepage: https://www.docker.com
482 + categories: [developer]
483 + tier: B
484 + discover: { rss: true, status: true }
485 + sensors:
486 + - { name: blog feed, url: "https://www.docker.com/blog/feed/", type: RSS, connector: rss, tier: B }
487 + - { name: docker engine releases, url: "https://github.com/moby/moby/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: moby/moby, kind: releases } }
488 + - { name: status, url: "https://www.dockerstatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
489 + - id: kubernetes
490 + name: Kubernetes
491 + domain: kubernetes.io
492 + categories: [developer, cloud]
493 + tier: A
494 + aliases: [k8s]
495 + discover: { rss: false }
496 + sensors:
497 + - { name: blog feed, url: "https://kubernetes.io/feed.xml", type: RSS, connector: rss, tier: B }
498 + - { name: releases, url: "https://github.com/kubernetes/kubernetes/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: kubernetes/kubernetes, kind: releases } }
499 + - id: nodejs
500 + name: Node.js
501 + domain: nodejs.org
502 + categories: [developer]
503 + tier: A
504 + aliases: [node.js, node]
505 + discover: { rss: false }
506 + sensors:
507 + - { name: releases feed, url: "https://nodejs.org/en/feed/releases.xml", type: RSS, connector: rss, tier: A }
508 + - { name: security feed, url: "https://nodejs.org/en/feed/vulnerability.xml", type: RSS, connector: rss, tier: S }
509 + - id: python
510 + name: Python
511 + domain: python.org
512 + homepage: https://www.python.org
513 + categories: [developer]
514 + tier: A
515 + aliases: [cpython, psf]
516 + discover: { rss: false }
517 + sensors:
518 + - { name: peps feed, url: "https://peps.python.org/peps.rss", type: RSS, connector: rss, tier: B }
519 + - { name: releases, url: "https://github.com/python/cpython/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: python/cpython, kind: releases } }
520 + - { name: blog feed, url: "https://blog.python.org/feeds/posts/default", type: ATOM, connector: rss, tier: B }
521 + - id: rust
522 + name: Rust
523 + domain: rust-lang.org
524 + homepage: https://www.rust-lang.org
525 + categories: [developer]
526 + tier: B
527 + aliases: [rust language, rustlang]
528 + discover: { rss: false }
529 + sensors:
530 + - { name: blog feed, url: "https://blog.rust-lang.org/feed.xml", type: ATOM, connector: rss, tier: B }
531 + - { name: releases, url: "https://github.com/rust-lang/rust/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: rust-lang/rust, kind: releases } }
532 + - id: go
533 + name: Go
534 + domain: go.dev
535 + categories: [developer]
536 + tier: B
537 + aliases: [golang]
538 + discover: { rss: false }
539 + sensors:
540 + - { name: blog feed, url: "https://go.dev/blog/feed.atom", type: ATOM, connector: rss, tier: B }
541 + - { name: releases, url: "https://github.com/golang/go/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: golang/go, kind: tags } }
542 + - id: php
543 + name: PHP
544 + domain: php.net
545 + homepage: https://www.php.net
546 + categories: [developer]
547 + tier: B
548 + discover: { rss: false }
549 + sensors:
550 + - { name: news feed, url: "https://www.php.net/feed.atom", type: ATOM, connector: rss, tier: B }
551 + - id: ruby
552 + name: Ruby
553 + domain: ruby-lang.org
554 + homepage: https://www.ruby-lang.org
555 + categories: [developer]
556 + tier: B
557 + discover: { rss: false }
558 + sensors:
559 + - { name: news feed, url: "https://www.ruby-lang.org/en/feeds/news.rss", type: RSS, connector: rss, tier: B }
560 + - id: postgresql
561 + name: PostgreSQL
562 + domain: postgresql.org
563 + homepage: https://www.postgresql.org
564 + categories: [developer]
565 + tier: B
566 + aliases: [postgres]
567 + discover: { rss: false }
568 + sensors:
569 + - { name: news feed, url: "https://www.postgresql.org/news.rss", type: RSS, connector: rss, tier: B }
570 + - id: mysql
571 + name: MySQL
572 + domain: mysql.com
573 + homepage: https://www.mysql.com
574 + categories: [developer]
575 + tier: C
576 + discover: { rss: true, pages: true }
577 + sensors:
578 + - { name: releases, url: "https://github.com/mysql/mysql-server/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mysql/mysql-server, kind: tags } }
579 + - id: redis
580 + name: Redis
581 + domain: redis.io
582 + categories: [developer]
583 + tier: B
584 + discover: { rss: true }
585 + sensors:
586 + - { name: releases, url: "https://github.com/redis/redis/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: redis/redis, kind: releases } }
587 + - id: mongodb
588 + name: MongoDB
589 + domain: mongodb.com
590 + homepage: https://www.mongodb.com
591 + categories: [developer]
592 + tier: B
593 + discover: { rss: true, status: true }
594 + sensors:
595 + - { name: status, url: "https://status.mongodb.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
596 + - { name: server releases, url: "https://github.com/mongodb/mongo/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: mongodb/mongo, kind: tags } }
597 + - id: elastic
598 + name: Elastic
599 + domain: elastic.co
600 + homepage: https://www.elastic.co
601 + categories: [developer, cyber]
602 + tier: B
603 + aliases: [elasticsearch]
604 + discover: { rss: true, status: true }
605 + sensors:
606 + - { name: status, url: "https://status.elastic.co/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
607 + - { name: elasticsearch releases, url: "https://github.com/elastic/elasticsearch/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: elastic/elasticsearch, kind: releases } }
608 + - { name: security announcements, url: "https://discuss.elastic.co/c/announcements/security-announcements/31.rss", type: RSS, connector: rss, tier: A }
609 + - id: hashicorp
610 + name: HashiCorp
611 + domain: hashicorp.com
612 + homepage: https://www.hashicorp.com
613 + categories: [developer, cloud]
614 + tier: B
615 + products: [{ name: Terraform, type: software }, { name: Vault, type: software }]
616 + discover: { rss: true, status: true }
617 + sensors:
618 + - { name: status, url: "https://status.hashicorp.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
619 + - { name: terraform releases, url: "https://github.com/hashicorp/terraform/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hashicorp/terraform, kind: releases } }
620 + - { name: vault releases, url: "https://github.com/hashicorp/vault/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hashicorp/vault, kind: releases } }
621 + - id: grafana
622 + name: Grafana
623 + domain: grafana.com
624 + categories: [developer]
625 + tier: B
626 + discover: { rss: true, status: true }
627 + sensors:
628 + - { name: releases, url: "https://github.com/grafana/grafana/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: grafana/grafana, kind: releases } }
629 + - { name: security feed, url: "https://grafana.com/security/security-advisories/index.xml", type: RSS, connector: rss, tier: A }
630 + - id: sentry
631 + name: Sentry
632 + domain: sentry.io
633 + categories: [developer]
634 + tier: B
635 + discover: { rss: true, status: true }
636 + sensors:
637 + - { name: status, url: "https://status.sentry.io/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
638 + - { name: changelog, url: "https://sentry.io/changelog/", type: HTML, connector: http, tier: B }
639 + - id: supabase
640 + name: Supabase
641 + domain: supabase.com
642 + categories: [developer, cloud]
643 + tier: B
644 + discover: { rss: true, status: true }
645 + sensors:
646 + - { name: status, url: "https://status.supabase.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
647 + - { name: changelog, url: "https://supabase.com/changelog", type: HTML, connector: http, tier: B }
648 + - { name: blog feed, url: "https://supabase.com/rss.xml", type: RSS, connector: rss, tier: B }
649 + - id: firebase
650 + name: Firebase
651 + domain: firebase.google.com
652 + categories: [developer, cloud]
653 + tier: B
654 + discover: { rss: false }
655 + sensors:
656 + - { name: release notes, url: "https://firebase.google.com/support/releases", type: HTML, connector: http, tier: B }
657 + - { name: status, url: "https://status.firebase.google.com/incidents.json", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleField: external_desc, urlField: uri, summaryField: most_recent_update.text, dateField: begin, compareFields: [end, most_recent_update.status], maxItems: 40, urlTemplate: "https://status.firebase.google.com/incidents/{key}" } }
658 +
659 + # ───────────────────────── D · Cybersecurity ─────────────────────────
660 + - id: cisa
661 + name: CISA
662 + domain: cisa.gov
663 + homepage: https://www.cisa.gov
664 + categories: [cyber, government]
665 + tier: S
666 + weight: 1.5
667 + aliases: [cybersecurity and infrastructure security agency]
668 + products: [{ name: KEV Catalog, type: dataset, aliases: [known exploited vulnerabilities, kev] }]
669 + discover: { rss: false }
670 + sensors:
671 + - name: known exploited vulnerabilities
672 + url: "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
673 + type: REST_API
674 + connector: jsonlist
675 + tier: S
676 + weight: 1.3
677 + config: { itemsPath: vulnerabilities, keyField: cveID, titleTemplate: "{cveID} — {vendorProject} {product}: {vulnerabilityName}", summaryField: shortDescription, dateField: dateAdded, urlTemplate: "https://www.cisa.gov/known-exploited-vulnerabilities-catalog?search_api_fulltext={key}", compareFields: [knownRansomwareCampaignUse, dueDate], maxItems: 400 }
678 + - { name: advisories feed, url: "https://www.cisa.gov/cybersecurity-advisories/all.xml", type: RSS, connector: rss, tier: S }
679 + - { name: news feed, url: "https://www.cisa.gov/news.xml", type: RSS, connector: rss, tier: A }
680 + - id: nvd
681 + name: NVD
682 + domain: nvd.nist.gov
683 + categories: [cyber, government]
684 + tier: S
685 + weight: 1.3
686 + aliases: [national vulnerability database]
687 + discover: { rss: false }
688 + sensors:
689 + - name: recent cves
690 + url: "https://services.nvd.nist.gov/rest/json/cves/2.0?lastModStartDate={now-3h}&lastModEndDate={now}&resultsPerPage=200"
691 + type: REST_API
692 + connector: jsonlist
693 + tier: A
694 + interval: 900
695 + config: { itemsPath: vulnerabilities, keyField: cve.id, titleTemplate: "{cve.id} — {cve.vulnStatus}", summaryField: cve.descriptions, dateField: cve.published, urlTemplate: "https://nvd.nist.gov/vuln/detail/{key}", compareFields: [cve.vulnStatus], noConditional: true, maxItems: 200 }
696 + - id: nist
697 + name: NIST
698 + domain: nist.gov
699 + homepage: https://www.nist.gov
700 + categories: [cyber, standards, government]
701 + tier: B
702 + discover: { rss: false }
703 + sensors:
704 + - { name: news feed, url: "https://www.nist.gov/news-events/news/rss.xml", type: RSS, connector: rss, tier: B }
705 + - { name: cybersecurity publications, url: "https://csrc.nist.gov/publications/sp", type: HTML, connector: http, tier: B }
706 + - id: cert-cc
707 + name: CERT/CC
708 + domain: cert.org
709 + homepage: https://www.kb.cert.org/vuls/
710 + categories: [cyber]
711 + tier: A
712 + aliases: [cert coordination center]
713 + discover: { rss: false }
714 + sensors:
715 + - { name: vulnerability notes feed, url: "https://www.kb.cert.org/vulfeed/", type: RSS, connector: rss, tier: A }
716 + - id: mitre
717 + name: MITRE
718 + domain: mitre.org
719 + homepage: https://www.mitre.org
720 + categories: [cyber]
721 + tier: B
722 + aliases: [cve program, att&ck, attack]
723 + discover: { rss: true }
724 + sensors:
725 + - { name: cve list releases, url: "https://github.com/CVEProject/cvelistV5/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: CVEProject/cvelistV5, kind: releases } }
726 + - { name: attack releases, url: "https://github.com/mitre-attack/attack-stix-data/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mitre-attack/attack-stix-data, kind: releases } }
727 + - id: microsoft-security
728 + name: Microsoft Security
729 + domain: msrc.microsoft.com
730 + homepage: https://msrc.microsoft.com
731 + categories: [cyber, technology]
732 + tier: S
733 + aliases: [msrc, microsoft security response center]
734 + discover: { rss: false }
735 + sensors:
736 + - { name: msrc blog, url: "https://msrc.microsoft.com/blog/", type: HTML, connector: http, tier: A }
737 + - name: security updates
738 + url: "https://api.msrc.microsoft.com/cvrf/v3.0/updates"
739 + type: REST_API
740 + connector: jsonlist
741 + tier: A
742 + config: { itemsPath: value, keyField: ID, titleField: DocumentTitle, urlField: CvrfUrl, dateField: CurrentReleaseDate, compareFields: [CurrentReleaseDate], maxItems: 40, headers: { accept: application/json } }
743 + - id: apple-security
744 + name: Apple Security
745 + domain: support.apple.com
746 + categories: [cyber, consumer-tech]
747 + tier: S
748 + aliases: [apple security updates]
749 + discover: { rss: false }
750 + sensors:
751 + - { name: security releases, url: "https://support.apple.com/en-us/100100", type: HTML, connector: http, tier: S }
752 + - id: google-security-blog
753 + name: Google Security Blog
754 + domain: security.googleblog.com
755 + categories: [cyber]
756 + tier: B
757 + discover: { rss: false }
758 + sensors:
759 + - { name: feed, url: "https://security.googleblog.com/feeds/posts/default?max-results=15", type: ATOM, connector: rss, tier: B }
760 + - id: project-zero
761 + name: Google Project Zero
762 + domain: googleprojectzero.blogspot.com
763 + categories: [cyber]
764 + tier: B
765 + aliases: [project zero]
766 + discover: { rss: false }
767 + sensors:
768 + - { name: feed, url: "https://googleprojectzero.blogspot.com/feeds/posts/summary?max-results=10", type: ATOM, connector: rss, tier: B }
769 + - id: cisco-security
770 + name: Cisco Security
771 + domain: cisco.com
772 + homepage: https://sec.cloudapps.cisco.com/security/center/publicationListing.x
773 + categories: [cyber, technology]
774 + tier: A
775 + aliases: [cisco, cisco psirt]
776 + discover: { rss: false }
777 + sensors:
778 + - { name: psirt advisories feed, url: "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml", type: RSS, connector: rss, tier: A }
779 + - id: palo-alto-networks
780 + name: Palo Alto Networks
781 + domain: paloaltonetworks.com
782 + homepage: https://www.paloaltonetworks.com
783 + categories: [cyber]
784 + tier: A
785 + aliases: [palo alto, unit 42]
786 + discover: { rss: false }
787 + sensors:
788 + - { name: security advisories feed, url: "https://security.paloaltonetworks.com/rss.xml", type: RSS, connector: rss, tier: A }
789 + - { name: unit 42 feed, url: "https://unit42.paloaltonetworks.com/feed/", type: RSS, connector: rss, tier: B }
790 + - id: fortinet
791 + name: Fortinet
792 + domain: fortinet.com
793 + homepage: https://www.fortinet.com
794 + categories: [cyber]
795 + tier: A
796 + aliases: [fortiguard]
797 + discover: { rss: false }
798 + sensors:
799 + - { name: psirt advisories feed, url: "https://filestore.fortinet.com/fortiguard/rss/ir.xml", type: RSS, connector: rss, tier: A }
800 + - id: crowdstrike
801 + name: CrowdStrike
802 + domain: crowdstrike.com
803 + homepage: https://www.crowdstrike.com
804 + categories: [cyber]
805 + tier: B
806 + discover: { rss: true }
807 + sensors:
808 + - { name: blog feed, url: "https://www.crowdstrike.com/en-us/blog/feed/", type: RSS, connector: rss, tier: B }
809 + - id: mandiant
810 + name: Mandiant
811 + domain: mandiant.com
812 + homepage: https://cloud.google.com/blog/topics/threat-intelligence
813 + categories: [cyber]
814 + tier: B
815 + aliases: [google threat intelligence]
816 + discover: { rss: false }
817 + sensors:
818 + - { name: threat intelligence feed, url: "https://cloudblog.withgoogle.com/topics/threat-intelligence/rss/", type: RSS, connector: rss, tier: B }
819 + - id: rapid7
820 + name: Rapid7
821 + domain: rapid7.com
822 + homepage: https://www.rapid7.com
823 + categories: [cyber]
824 + tier: B
825 + discover: { rss: false }
826 + sensors:
827 + - { name: blog feed, url: "https://blog.rapid7.com/rss/", type: RSS, connector: rss, tier: B }
828 + - { name: metasploit releases, url: "https://github.com/rapid7/metasploit-framework/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: rapid7/metasploit-framework, kind: tags } }
829 + - id: tenable
830 + name: Tenable
831 + domain: tenable.com
832 + homepage: https://www.tenable.com
833 + categories: [cyber]
834 + tier: B
835 + discover: { rss: false }
836 + sensors:
837 + - { name: research advisories feed, url: "https://www.tenable.com/security/research/feed", type: RSS, connector: rss, tier: B }
838 + - { name: blog feed, url: "https://www.tenable.com/blog/feed", type: RSS, connector: rss, tier: C }
839 + - id: sophos
840 + name: Sophos
841 + domain: sophos.com
842 + homepage: https://news.sophos.com
843 + categories: [cyber]
844 + tier: B
845 + discover: { rss: false }
846 + sensors:
847 + - { name: news feed, url: "https://www.sophos.com/en-us/blog/feed", type: RSS, connector: rss, tier: B }
848 + - id: trend-micro
849 + name: Trend Micro
850 + domain: trendmicro.com
851 + homepage: https://www.trendmicro.com
852 + categories: [cyber]
853 + tier: B
854 + discover: { rss: false }
855 + sensors:
856 + - { name: research, url: "https://www.trendmicro.com/en_us/research.html", type: HTML, connector: http, tier: B }
857 + - id: malwarebytes
858 + name: Malwarebytes
859 + domain: malwarebytes.com
860 + homepage: https://www.malwarebytes.com
861 + categories: [cyber]
862 + tier: C
863 + discover: { rss: false }
864 + sensors:
865 + - { name: labs feed, url: "https://www.malwarebytes.com/blog/feed/index.xml", type: RSS, connector: rss, tier: C }
866 + - id: hibp
867 + name: Have I Been Pwned
868 + domain: haveibeenpwned.com
869 + categories: [cyber]
870 + tier: A
871 + aliases: [hibp]
872 + discover: { rss: false }
873 + sensors:
874 + - name: breaches
875 + url: "https://haveibeenpwned.com/api/v3/breaches"
876 + type: REST_API
877 + connector: jsonlist
878 + tier: A
879 + config: { itemsPath: "", keyField: Name, titleTemplate: "{Title} — {PwnCount} accounts", summaryField: Description, dateField: AddedDate, urlTemplate: "https://haveibeenpwned.com/PwnedWebsites#{key}", compareFields: [PwnCount, IsVerified], maxItems: 2000 }
880 +
881 + # ───────────────────────── E · Consumer Technology ─────────────────────────
882 + - id: apple
883 + name: Apple
884 + domain: apple.com
885 + homepage: https://www.apple.com
886 + categories: [consumer-tech, technology]
887 + tier: S
888 + weight: 1.5
889 + products:
890 + - { name: iPhone, type: product }
891 + - { name: iOS, type: software }
892 + - { name: macOS, type: software }
893 + - { name: Mac, type: product, aliases: [macbook, macbook pro, macbook air, mac studio, mac mini] }
894 + - { name: Apple Intelligence, type: product }
895 + - { name: Vision Pro, type: product }
896 + discover: { rss: false, sitemap: false }
897 + sensors:
898 + - { name: newsroom feed, url: "https://www.apple.com/newsroom/rss-feed.rss", type: RSS, connector: rss, tier: S }
899 + - { name: developer news feed, url: "https://developer.apple.com/news/rss/news.rss", type: RSS, connector: rss, tier: A }
900 + - { name: developer releases feed, url: "https://developer.apple.com/news/releases/rss/releases.rss", type: RSS, connector: rss, tier: A }
901 + - { name: mac pricing, url: "https://www.apple.com/shop/buy-mac/macbook-pro", type: HTML, connector: http, tier: B }
902 + - { name: system status, url: "https://www.apple.com/support/systemstatus/data/system_status_en_US.js", type: JSON, connector: http, tier: S, config: { ignoreKeys: [] } }
903 + - id: samsung
904 + name: Samsung
905 + domain: samsung.com
906 + homepage: https://news.samsung.com/global/
907 + categories: [consumer-tech, semiconductors]
908 + tier: B
909 + products: [{ name: Galaxy, type: product, aliases: [galaxy s, galaxy z] }]
910 + discover: { rss: false }
911 + sensors:
912 + - { name: newsroom feed, url: "https://news.samsung.com/global/feed", type: RSS, connector: rss, tier: B }
913 + - id: google
914 + name: Google
915 + domain: google.com
916 + homepage: https://blog.google
917 + categories: [technology, internet]
918 + tier: A
919 + weight: 1.3
920 + aliases: [alphabet]
921 + products: [{ name: Android, type: software }, { name: Chrome, type: software }, { name: Pixel, type: product }]
922 + discover: { rss: false }
923 + sensors:
924 + - { name: keyword blog feed, url: "https://blog.google/rss/", type: RSS, connector: rss, tier: A }
925 + - { name: chrome releases feed, url: "https://chromereleases.googleblog.com/feeds/posts/default", type: ATOM, connector: rss, tier: A }
926 + - { name: workspace status, url: "https://www.google.com/appsstatus/dashboard/incidents.json", type: REST_API, connector: jsonlist, tier: S, config: { itemsPath: "", keyField: id, titleField: external_desc, urlField: uri, summaryField: most_recent_update.text, dateField: begin, compareFields: [end, most_recent_update.status], maxItems: 40, urlTemplate: "https://www.google.com/appsstatus/dashboard/incidents/{key}" } }
927 + - id: microsoft
928 + name: Microsoft
929 + domain: microsoft.com
930 + homepage: https://news.microsoft.com
931 + categories: [technology, enterprise]
932 + tier: A
933 + weight: 1.3
934 + products: [{ name: Windows, type: software }, { name: Microsoft 365, type: product, aliases: [office 365] }, { name: Xbox, type: product }]
935 + discover: { rss: false }
936 + sensors:
937 + - { name: news feed, url: "https://news.microsoft.com/source/feed/", type: RSS, connector: rss, tier: A }
938 + - { name: windows blog feed, url: "https://blogs.windows.com/feed/", type: RSS, connector: rss, tier: B }
939 + - id: sony
940 + name: Sony
941 + domain: sony.com
942 + homepage: https://www.sony.com/en/SonyInfo/News/
943 + categories: [consumer-tech]
944 + tier: C
945 + products: [{ name: PlayStation, type: product, aliases: [ps5] }]
946 + discover: { rss: true, sitemap: true, pages: true }
947 + - id: lg
948 + name: LG
949 + domain: lg.com
950 + homepage: https://www.lgnewsroom.com
951 + categories: [consumer-tech]
952 + tier: C
953 + aliases: [lg electronics]
954 + discover: { rss: true }
955 + - id: dell
956 + name: Dell
957 + domain: dell.com
958 + homepage: https://www.dell.com
959 + categories: [consumer-tech, enterprise]
960 + tier: C
961 + discover: { rss: true, pages: true }
962 + - id: hp
963 + name: HP
964 + domain: hp.com
965 + homepage: https://press.hp.com
966 + categories: [consumer-tech]
967 + tier: C
968 + discover: { rss: true }
969 + sensors:
970 + - { name: press releases, url: "https://press.hp.com/us/en/press-releases.html", type: HTML, connector: http, tier: C }
971 + - id: lenovo
972 + name: Lenovo
973 + domain: lenovo.com
974 + homepage: https://news.lenovo.com
975 + categories: [consumer-tech]
976 + tier: C
977 + discover: { rss: true }
978 + sensors:
979 + - { name: news feed, url: "https://news.lenovo.com/feed/", type: RSS, connector: rss, tier: C }
980 + - id: asus
981 + name: ASUS
982 + domain: asus.com
983 + homepage: https://www.asus.com
984 + categories: [consumer-tech]
985 + tier: C
986 + discover: { rss: true, pages: true }
987 + sensors:
988 + - { name: news, url: "https://www.asus.com/news/", type: HTML, connector: http, tier: C }
989 + - { name: security advisories, url: "https://www.asus.com/content/asus-product-security-advisory/", type: HTML, connector: http, tier: B }
990 + - id: acer
991 + name: Acer
992 + domain: acer.com
993 + homepage: https://news.acer.com
994 + categories: [consumer-tech]
995 + tier: D
996 + discover: { rss: true }
997 + sensors:
998 + - { name: news feed, url: "https://news.acer.com/feed.xml", type: RSS, connector: rss, tier: C }
999 + - id: intel
1000 + name: Intel
1001 + domain: intel.com
1002 + homepage: https://www.intel.com
1003 + categories: [semiconductors, technology]
1004 + tier: B
1005 + products: [{ name: Core Ultra, type: product }, { name: Xeon, type: product }, { name: Gaudi, type: product }]
1006 + discover: { rss: false }
1007 + sensors:
1008 + - { name: newsroom, url: "https://newsroom.intel.com/", type: HTML, connector: http, tier: B }
1009 + - { name: security advisories, url: "https://www.intel.com/content/www/us/en/security-center/default.html", type: HTML, connector: http, tier: A }
1010 + - id: amd
1011 + name: AMD
1012 + domain: amd.com
1013 + homepage: https://www.amd.com
1014 + categories: [semiconductors, technology]
1015 + tier: B
1016 + products: [{ name: Ryzen, type: product }, { name: EPYC, type: product }, { name: Radeon, type: product }, { name: Instinct, type: product, aliases: [mi300, mi350] }]
1017 + discover: { rss: false, pages: true }
1018 + sensors:
1019 + - { name: press releases, url: "https://www.amd.com/en/newsroom.html", type: HTML, connector: http, tier: B }
1020 + - { name: security bulletins, url: "https://www.amd.com/en/resources/product-security.html", type: HTML, connector: http, tier: A }
1021 + - id: qualcomm
1022 + name: Qualcomm
1023 + domain: qualcomm.com
1024 + homepage: https://www.qualcomm.com
1025 + categories: [semiconductors]
1026 + tier: C
1027 + products: [{ name: Snapdragon, type: product }]
1028 + discover: { rss: true, pages: true }
1029 + sensors:
1030 + - { name: news releases, url: "https://www.qualcomm.com/news/releases", type: HTML, connector: http, tier: C }
1031 + - { name: security bulletins, url: "https://docs.qualcomm.com/product/publicresources/securitybulletin/", type: HTML, connector: http, tier: B }
1032 + - id: arm
1033 + name: Arm
1034 + domain: arm.com
1035 + homepage: https://newsroom.arm.com
1036 + categories: [semiconductors]
1037 + tier: C
1038 + aliases: [arm holdings]
1039 + discover: { rss: true }
1040 + sensors:
1041 + - { name: newsroom feed, url: "https://newsroom.arm.com/rss", type: RSS, connector: rss, tier: C }
1042 + - id: tsmc
1043 + name: TSMC
1044 + domain: tsmc.com
1045 + homepage: https://pr.tsmc.com
1046 + categories: [semiconductors]
1047 + tier: C
1048 + discover: { rss: true, pages: true }
1049 + - id: micron
1050 + name: Micron
1051 + domain: micron.com
1052 + homepage: https://investors.micron.com
1053 + categories: [semiconductors]
1054 + tier: C
1055 + discover: { rss: true }
1056 + - id: western-digital
1057 + name: Western Digital
1058 + domain: westerndigital.com
1059 + homepage: https://www.westerndigital.com
1060 + categories: [consumer-tech]
1061 + tier: D
1062 + discover: { rss: true, pages: true }
1063 + sensors:
1064 + - { name: newsroom, url: "https://www.westerndigital.com/company/newsroom", type: HTML, connector: http, tier: D }
1065 + - id: seagate
1066 + name: Seagate
1067 + domain: seagate.com
1068 + homepage: https://www.seagate.com
1069 + categories: [consumer-tech]
1070 + tier: D
1071 + discover: { rss: true, pages: true }
1072 + sensors:
1073 + - { name: news, url: "https://www.seagate.com/news/", type: HTML, connector: http, tier: D }
1074 + - id: raspberry-pi
1075 + name: Raspberry Pi
1076 + domain: raspberrypi.com
1077 + homepage: https://www.raspberrypi.com
1078 + categories: [consumer-tech, developer]
1079 + tier: C
1080 + discover: { rss: false }
1081 + sensors:
1082 + - { name: news feed, url: "https://www.raspberrypi.com/news/feed/", type: RSS, connector: rss, tier: C }
1083 +
1084 + # ───────────────────────── F · Financial Markets & Regulators ─────────────────────────
1085 + - id: sec
1086 + name: SEC
1087 + domain: sec.gov
1088 + homepage: https://www.sec.gov
1089 + categories: [finance, government]
1090 + tier: S
1091 + weight: 1.4
1092 + aliases: [securities and exchange commission, edgar]
1093 + discover: { rss: false }
1094 + sensors:
1095 + - { name: press releases feed, url: "https://www.sec.gov/news/pressreleases.rss", type: RSS, connector: rss, tier: S, config: { } }
1096 + - { name: litigation releases feed, url: "https://www.sec.gov/enforcement-litigation/litigation-releases/rss", type: RSS, connector: rss, tier: A }
1097 + - id: federal-reserve
1098 + name: Federal Reserve
1099 + domain: federalreserve.gov
1100 + homepage: https://www.federalreserve.gov
1101 + categories: [finance, government]
1102 + tier: S
1103 + weight: 1.5
1104 + aliases: [fed, fomc, the fed]
1105 + discover: { rss: false }
1106 + sensors:
1107 + - { name: press releases feed, url: "https://www.federalreserve.gov/feeds/press_all.xml", type: RSS, connector: rss, tier: S }
1108 + - { name: speeches feed, url: "https://www.federalreserve.gov/feeds/speeches.xml", type: RSS, connector: rss, tier: A }
1109 + - id: us-treasury
1110 + name: U.S. Treasury
1111 + domain: treasury.gov
1112 + homepage: https://home.treasury.gov
1113 + categories: [finance, government]
1114 + tier: A
1115 + aliases: [treasury, ofac]
1116 + discover: { rss: false }
1117 + sensors:
1118 + - { name: press releases feed, url: "https://home.treasury.gov/news/press-releases/feed", type: RSS, connector: rss, tier: A }
1119 + - { name: ofac recent actions feed, url: "https://ofac.treasury.gov/rss.xml", type: RSS, connector: rss, tier: A }
1120 + - id: bank-of-canada
1121 + name: Bank of Canada
1122 + domain: bankofcanada.ca
1123 + homepage: https://www.bankofcanada.ca
1124 + categories: [finance, government]
1125 + tier: S
1126 + weight: 1.3
1127 + aliases: [banque du canada, boc]
1128 + discover: { rss: false }
1129 + sensors:
1130 + - { name: press releases feed, url: "https://www.bankofcanada.ca/content_type/press-releases/feed/", type: RSS, connector: rss, tier: S }
1131 + - { name: speeches feed, url: "https://www.bankofcanada.ca/content_type/speeches/feed/", type: RSS, connector: rss, tier: A }
1132 + - { name: policy rate, url: "https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/", type: HTML, connector: http, tier: A }
1133 + - id: ecb
1134 + name: ECB
1135 + domain: ecb.europa.eu
1136 + homepage: https://www.ecb.europa.eu
1137 + categories: [finance, government]
1138 + tier: S
1139 + aliases: [european central bank]
1140 + discover: { rss: false }
1141 + sensors:
1142 + - { name: press releases feed, url: "https://www.ecb.europa.eu/rss/press.html", type: RSS, connector: rss, tier: S }
1143 + - id: bank-of-england
1144 + name: Bank of England
1145 + domain: bankofengland.co.uk
1146 + homepage: https://www.bankofengland.co.uk
1147 + categories: [finance, government]
1148 + tier: A
1149 + aliases: [boe]
1150 + discover: { rss: false }
1151 + sensors:
1152 + - { name: news feed, url: "https://www.bankofengland.co.uk/rss/news", type: RSS, connector: rss, tier: A }
1153 + - id: bank-of-japan
1154 + name: Bank of Japan
1155 + domain: boj.or.jp
1156 + homepage: https://www.boj.or.jp/en/
1157 + categories: [finance, government]
1158 + tier: A
1159 + aliases: [boj]
1160 + discover: { rss: false }
1161 + sensors:
1162 + - { name: whats new feed, url: "https://www.boj.or.jp/en/rss/whatsnew.xml", type: RSS, connector: rss, tier: A }
1163 + - id: finra
1164 + name: FINRA
1165 + domain: finra.org
1166 + homepage: https://www.finra.org
1167 + categories: [finance]
1168 + tier: B
1169 + discover: { rss: false }
1170 + sensors:
1171 + - { name: news releases feed, url: "https://www.finra.org/rss.xml", type: RSS, connector: rss, tier: B }
1172 + - { name: regulatory notices, url: "https://www.finra.org/rules-guidance/notices", type: HTML, connector: http, tier: B }
1173 + - id: cftc
1174 + name: CFTC
1175 + domain: cftc.gov
1176 + homepage: https://www.cftc.gov
1177 + categories: [finance, government]
1178 + tier: B
1179 + discover: { rss: false }
1180 + sensors:
1181 + - { name: press releases feed, url: "https://www.cftc.gov/RSS/RSSGP/rssgp.xml", type: RSS, connector: rss, tier: B }
1182 + - id: nasdaq
1183 + name: Nasdaq
1184 + domain: nasdaq.com
1185 + homepage: https://www.nasdaq.com
1186 + categories: [finance]
1187 + tier: B
1188 + discover: { rss: false }
1189 + sensors:
1190 + - { name: trader news, url: "https://www.nasdaqtrader.com/Trader.aspx?id=MarketSystemStatus", type: HTML, connector: http, tier: A }
1191 + - { name: ir press releases feed, url: "https://ir.nasdaq.com/rss/news-releases.xml", type: RSS, connector: rss, tier: B }
1192 + - id: nyse
1193 + name: NYSE
1194 + domain: nyse.com
1195 + homepage: https://www.nyse.com
1196 + categories: [finance]
1197 + tier: B
1198 + aliases: [new york stock exchange]
1199 + discover: { rss: true, pages: true }
1200 + - id: tmx
1201 + name: TMX Group
1202 + domain: tmx.com
1203 + homepage: https://www.tmx.com
1204 + categories: [finance]
1205 + tier: B
1206 + aliases: [tsx, toronto stock exchange]
1207 + discover: { rss: true, pages: true }
1208 + sensors:
1209 + - { name: news releases, url: "https://www.tmx.com/newsroom", type: HTML, connector: http, tier: B }
1210 + - id: sedar-plus
1211 + name: SEDAR+
1212 + domain: sedarplus.ca
1213 + homepage: https://www.sedarplus.ca
1214 + categories: [finance, government]
1215 + tier: C
1216 + aliases: [sedar, csa]
1217 + discover: { rss: true, pages: true }
1218 + sensors:
1219 + - { name: landing, url: "https://www.sedarplus.ca/landingpage/", type: HTML, connector: http, tier: C }
1220 + - id: fdic
1221 + name: FDIC
1222 + domain: fdic.gov
1223 + homepage: https://www.fdic.gov
1224 + categories: [finance, government]
1225 + tier: B
1226 + discover: { rss: false }
1227 + sensors:
1228 + - { name: press releases feed, url: "https://www.fdic.gov/rss.xml", type: RSS, connector: rss, tier: B }
1229 + - { name: failed bank list, url: "https://www.fdic.gov/bank-failures/failed-bank-list", type: HTML, connector: http, tier: A }
1230 + - id: occ
1231 + name: OCC
1232 + domain: occ.gov
1233 + homepage: https://www.occ.gov
1234 + categories: [finance, government]
1235 + tier: C
1236 + aliases: [office of the comptroller of the currency]
1237 + discover: { rss: false }
1238 + sensors:
1239 + - { name: news releases feed, url: "https://www.occ.gov/rss/occ_news.xml", type: RSS, connector: rss, tier: B }
1240 + - id: bis
1241 + name: BIS
1242 + domain: bis.org
1243 + homepage: https://www.bis.org
1244 + categories: [finance]
1245 + tier: C
1246 + aliases: [bank for international settlements]
1247 + discover: { rss: false }
1248 + sensors:
1249 + - { name: press releases feed, url: "https://www.bis.org/rss.xml", type: RSS, connector: rss, tier: B }
1250 + - id: imf
1251 + name: IMF
1252 + domain: imf.org
1253 + homepage: https://www.imf.org
1254 + categories: [finance, government]
1255 + tier: B
1256 + aliases: [international monetary fund]
1257 + discover: { rss: false }
1258 + - id: world-bank
1259 + name: World Bank
1260 + domain: worldbank.org
1261 + homepage: https://www.worldbank.org
1262 + categories: [finance, government]
1263 + tier: C
1264 + discover: { rss: false }
1265 + sensors:
1266 + - { name: news, url: "https://www.worldbank.org/en/news/all", type: HTML, connector: http, tier: C }
1267 + - id: oecd
1268 + name: OECD
1269 + domain: oecd.org
1270 + homepage: https://www.oecd.org
1271 + categories: [finance, statistics, government]
1272 + tier: C
1273 + discover: { rss: true, pages: true }
1274 + - id: fred
1275 + name: FRED (St. Louis Fed)
1276 + domain: fred.stlouisfed.org
1277 + categories: [finance, statistics]
1278 + tier: B
1279 + aliases: [fred, st. louis fed]
1280 + discover: { rss: false }
1281 + sensors:
1282 + - { name: release calendar today, url: "https://fred.stlouisfed.org/releases/calendar", type: HTML, connector: http, tier: B }
1283 +
1284 + # ───────────────────────── G · Government & Statistics ─────────────────────────
1285 + - id: canada
1286 + name: Government of Canada
1287 + domain: canada.ca
1288 + homepage: https://www.canada.ca
1289 + categories: [government]
1290 + tier: A
1291 + aliases: [gouvernement du canada, government of canada]
1292 + discover: { rss: false }
1293 + sensors:
1294 + - { name: national news feed, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=departmentofjustice,financecanada,innovationsciencedeveloppement&type=newsreleases&sort=publishedDate&orderBy=desc&publishedDate%3E=2024-01-01&pick=50&format=atom&atomtitle=National%20News", type: ATOM, connector: rss, tier: A }
1295 + - { name: all news feed, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?sort=publishedDate&orderBy=desc&pick=100&format=atom&atomtitle=Canada%20News", type: ATOM, connector: rss, tier: A, config: { maxItems: 100 } }
1296 + - id: statcan
1297 + name: Statistics Canada
1298 + domain: statcan.gc.ca
1299 + homepage: https://www.statcan.gc.ca
1300 + categories: [statistics, government]
1301 + tier: S
1302 + weight: 1.3
1303 + aliases: [statistique canada, statistics canada]
1304 + discover: { rss: false }
1305 + sensors:
1306 + - { name: news releases feed, url: "https://www.statcan.gc.ca/en/rss.xml", type: RSS, connector: rss, tier: S }
1307 + - { name: the daily, url: "https://www150.statcan.gc.ca/n1/dai-quo/index-eng.htm", type: HTML, connector: http, tier: S }
1308 + - { name: release schedule, url: "https://www150.statcan.gc.ca/n1/dai-quo/cal2-eng.htm", type: HTML, connector: http, tier: B }
1309 + - id: quebec
1310 + name: Gouvernement du Québec
1311 + domain: quebec.ca
1312 + homepage: https://www.quebec.ca
1313 + categories: [government]
1314 + tier: B
1315 + aliases: [government of quebec, gouvernement du québec, québec]
1316 + discover: { rss: false }
1317 + sensors:
1318 + - { name: actualités, url: "https://www.quebec.ca/nouvelles/actualites", type: HTML, connector: http, tier: B }
1319 + - id: federal-register
1320 + name: U.S. Federal Register
1321 + domain: federalregister.gov
1322 + homepage: https://www.federalregister.gov
1323 + categories: [government]
1324 + tier: A
1325 + aliases: [federal register]
1326 + discover: { rss: false }
1327 + sensors:
1328 + - name: rules published
1329 + url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=100&conditions[type][]=RULE"
1330 + type: REST_API
1331 + connector: jsonlist
1332 + tier: A
1333 + config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, compareFields: [], maxItems: 100 }
1334 + - name: proposed rules
1335 + url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=100&conditions[type][]=PRORULE"
1336 + type: REST_API
1337 + connector: jsonlist
1338 + tier: B
1339 + config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, compareFields: [], maxItems: 100 }
1340 + - name: presidential documents
1341 + url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[type][]=PRESDOCU"
1342 + type: REST_API
1343 + connector: jsonlist
1344 + tier: A
1345 + config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, compareFields: [], maxItems: 50 }
1346 + - id: white-house
1347 + name: White House
1348 + domain: whitehouse.gov
1349 + homepage: https://www.whitehouse.gov
1350 + categories: [government]
1351 + tier: A
1352 + discover: { rss: false }
1353 + sensors:
1354 + - { name: news feed, url: "https://www.whitehouse.gov/news/feed/", type: RSS, connector: rss, tier: A }
1355 + - { name: presidential actions feed, url: "https://www.whitehouse.gov/presidential-actions/feed/", type: RSS, connector: rss, tier: A }
1356 + - id: congress
1357 + name: U.S. Congress
1358 + domain: congress.gov
1359 + homepage: https://www.congress.gov
1360 + categories: [government]
1361 + tier: B
1362 + discover: { rss: false }
1363 + sensors:
1364 + - { name: most viewed bills feed, url: "https://www.congress.gov/rss/most-viewed-bills.xml", type: RSS, connector: rss, tier: B }
1365 + - { name: presented to president feed, url: "https://www.congress.gov/rss/presented-to-president.xml", type: RSS, connector: rss, tier: A }
1366 + - id: bls
1367 + name: BLS
1368 + domain: bls.gov
1369 + homepage: https://www.bls.gov
1370 + categories: [statistics, government, finance]
1371 + tier: S
1372 + weight: 1.3
1373 + aliases: [bureau of labor statistics]
1374 + discover: { rss: false }
1375 + sensors:
1376 + - { name: news releases feed, url: "https://www.bls.gov/feed/bls_latest.rss", type: RSS, connector: rss, tier: S }
1377 + - { name: release schedule, url: "https://www.bls.gov/schedule/news_release/", type: HTML, connector: http, tier: B }
1378 + - id: census
1379 + name: U.S. Census Bureau
1380 + domain: census.gov
1381 + homepage: https://www.census.gov
1382 + categories: [statistics, government]
1383 + tier: B
1384 + aliases: [census bureau, census]
1385 + discover: { rss: false }
1386 + sensors:
1387 + - { name: economic indicators feed, url: "https://www.census.gov/economic-indicators/indicator.xml", type: RSS, connector: rss, tier: A }
1388 + - { name: press releases feed, url: "https://www.census.gov/newsroom/press-releases.xml", type: RSS, connector: rss, tier: B }
1389 + - id: bea
1390 + name: BEA
1391 + domain: bea.gov
1392 + homepage: https://www.bea.gov
1393 + categories: [statistics, government, finance]
1394 + tier: A
1395 + aliases: [bureau of economic analysis]
1396 + discover: { rss: false }
1397 + sensors:
1398 + - { name: news releases feed, url: "https://apps.bea.gov/rss/rss.xml", type: RSS, connector: rss, tier: A }
1399 + - id: eurostat
1400 + name: Eurostat
1401 + domain: ec.europa.eu
1402 + homepage: https://ec.europa.eu/eurostat
1403 + categories: [statistics, government]
1404 + tier: B
1405 + discover: { rss: false }
1406 + sensors:
1407 + - { name: euro indicators, url: "https://ec.europa.eu/eurostat/news/euro-indicators", type: HTML, connector: http, tier: B }
1408 +
1409 + # ───────────────────────── H · Healthcare & Medicine ─────────────────────────
1410 + - id: fda
1411 + name: FDA
1412 + domain: fda.gov
1413 + homepage: https://www.fda.gov
1414 + categories: [health, pharma, government]
1415 + tier: S
1416 + weight: 1.4
1417 + aliases: [food and drug administration, u.s. fda]
1418 + discover: { rss: false }
1419 + sensors:
1420 + - { name: press announcements feed, url: "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/press-releases/rss.xml", type: RSS, connector: rss, tier: S }
1421 + - { name: recalls feed, url: "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/recalls/rss.xml", type: RSS, connector: rss, tier: S }
1422 + - { name: medwatch safety alerts feed, url: "https://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/medwatch/rss.xml", type: RSS, connector: rss, tier: A }
1423 + - { name: novel drug approvals 2026, url: "https://www.fda.gov/drugs/novel-drug-approvals-fda/novel-drug-approvals-2026", type: HTML, connector: http, tier: A }
1424 + - id: health-canada
1425 + name: Health Canada
1426 + domain: canada.ca
1427 + homepage: https://www.canada.ca/en/health-canada.html
1428 + categories: [health, government]
1429 + tier: A
1430 + aliases: [santé canada, health canada]
1431 + discover: { rss: false }
1432 + sensors:
1433 + - { name: recalls and safety alerts, url: "https://recalls-rappels.canada.ca/en", type: HTML, connector: http, tier: A }
1434 + - { name: health canada news feed, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=healthcanada,publichealthagencyofcanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Health%20Canada", type: ATOM, connector: rss, tier: A }
1435 + - id: ema
1436 + name: EMA
1437 + domain: ema.europa.eu
1438 + homepage: https://www.ema.europa.eu
1439 + categories: [health, pharma, government]
1440 + tier: A
1441 + aliases: [european medicines agency]
1442 + discover: { rss: false }
1443 + sensors:
1444 + - { name: news, url: "https://www.ema.europa.eu/en/news", type: HTML, connector: http, tier: A }
1445 + - { name: medicines under evaluation, url: "https://www.ema.europa.eu/en/medicines/medicines-human-use-under-evaluation", type: HTML, connector: http, tier: B }
1446 + - id: clinicaltrials
1447 + name: ClinicalTrials.gov
1448 + domain: clinicaltrials.gov
1449 + categories: [health, pharma, science]
1450 + tier: B
1451 + aliases: [clinicaltrials.gov]
1452 + discover: { rss: false }
1453 + sensors:
1454 + - name: phase 3 results posted
1455 + url: "https://clinicaltrials.gov/api/v2/studies?query.term=AREA%5BPhase%5DPHASE3&filter.overallStatus=COMPLETED&sort=LastUpdatePostDate%3Adesc&pageSize=50&format=json"
1456 + type: REST_API
1457 + connector: jsonlist
1458 + tier: C
1459 + config: { itemsPath: studies, keyField: protocolSection.identificationModule.nctId, titleTemplate: "{protocolSection.identificationModule.nctId} — {protocolSection.identificationModule.briefTitle}", summaryField: protocolSection.conditionsModule.conditions, dateField: protocolSection.statusModule.lastUpdatePostDateStruct.date, urlTemplate: "https://clinicaltrials.gov/study/{key}", compareFields: [protocolSection.statusModule.overallStatus], maxItems: 50, noConditional: true }
1460 + - id: pubmed
1461 + name: PubMed
1462 + domain: pubmed.ncbi.nlm.nih.gov
1463 + categories: [health, science]
1464 + tier: C
1465 + discover: { rss: false }
1466 + - id: nih
1467 + name: NIH
1468 + domain: nih.gov
1469 + homepage: https://www.nih.gov
1470 + categories: [health, science, government]
1471 + tier: B
1472 + aliases: [national institutes of health]
1473 + discover: { rss: false }
1474 + fallback: { scrapfly: true }
1475 + sensors:
1476 + - { name: news releases, url: "https://www.nih.gov/news-events/news-releases", type: HTML, connector: http, tier: C }
1477 + - id: cdc
1478 + name: CDC
1479 + domain: cdc.gov
1480 + homepage: https://www.cdc.gov
1481 + categories: [health, government]
1482 + tier: A
1483 + aliases: [centers for disease control]
1484 + discover: { rss: false }
1485 + sensors:
1486 + - { name: newsroom feed, url: "https://tools.cdc.gov/api/v2/resources/media/403372.rss", type: RSS, connector: rss, tier: A }
1487 + - id: who
1488 + name: WHO
1489 + domain: who.int
1490 + homepage: https://www.who.int
1491 + categories: [health, government]
1492 + tier: A
1493 + aliases: [world health organization]
1494 + discover: { rss: false }
1495 + sensors:
1496 + - { name: news feed, url: "https://www.who.int/rss-feeds/news-english.xml", type: RSS, connector: rss, tier: A }
1497 + - { name: disease outbreak news, url: "https://www.who.int/emergencies/disease-outbreak-news", type: HTML, connector: http, tier: S }
1498 + - id: mayo-clinic
1499 + name: Mayo Clinic
1500 + domain: mayoclinic.org
1501 + homepage: https://newsnetwork.mayoclinic.org
1502 + categories: [health]
1503 + tier: C
1504 + discover: { rss: false }
1505 + - id: cleveland-clinic
1506 + name: Cleveland Clinic
1507 + domain: clevelandclinic.org
1508 + homepage: https://newsroom.clevelandclinic.org
1509 + categories: [health]
1510 + tier: C
1511 + discover: { rss: false }
1512 + sensors:
1513 + - { name: newsroom, url: "https://newsroom.clevelandclinic.org/", type: HTML, connector: http, tier: C }
1514 + - id: pfizer
1515 + name: Pfizer
1516 + domain: pfizer.com
1517 + homepage: https://www.pfizer.com
1518 + categories: [pharma, health]
1519 + tier: B
1520 + discover: { rss: false }
1521 + sensors:
1522 + - { name: press releases feed, url: "https://www.pfizer.com/rss.xml", type: RSS, connector: rss, tier: B }
1523 + - { name: pipeline, url: "https://www.pfizer.com/science/drug-product-pipeline", type: HTML, connector: http, tier: B }
1524 + - id: moderna
1525 + name: Moderna
1526 + domain: modernatx.com
1527 + homepage: https://investors.modernatx.com
1528 + categories: [pharma, health]
1529 + tier: B
1530 + discover: { rss: false }
1531 + sensors:
1532 + - { name: news, url: "https://investors.modernatx.com/news/default.aspx", type: HTML, connector: http, tier: B }
1533 + - id: astrazeneca
1534 + name: AstraZeneca
1535 + domain: astrazeneca.com
1536 + homepage: https://www.astrazeneca.com
1537 + categories: [pharma, health]
1538 + tier: B
1539 + discover: { rss: false }
1540 + - id: roche
1541 + name: Roche
1542 + domain: roche.com
1543 + homepage: https://www.roche.com
1544 + categories: [pharma, health]
1545 + tier: B
1546 + discover: { rss: false }
1547 + sensors:
1548 + - { name: media releases, url: "https://www.roche.com/media/releases", type: HTML, connector: http, tier: B }
1549 + - id: novartis
1550 + name: Novartis
1551 + domain: novartis.com
1552 + homepage: https://www.novartis.com
1553 + categories: [pharma, health]
1554 + tier: B
1555 + discover: { rss: false }
1556 + sensors:
1557 + - { name: media releases, url: "https://www.novartis.com/news", type: HTML, connector: http, tier: B }
1558 + - id: merck
1559 + name: Merck
1560 + domain: merck.com
1561 + homepage: https://www.merck.com
1562 + categories: [pharma, health]
1563 + tier: B
1564 + aliases: [msd]
1565 + discover: { rss: false }
1566 + sensors:
1567 + - { name: news feed, url: "https://www.merck.com/media/news/feed/", type: RSS, connector: rss, tier: B }
1568 + - id: eli-lilly
1569 + name: Eli Lilly
1570 + domain: lilly.com
1571 + homepage: https://investor.lilly.com
1572 + categories: [pharma, health]
1573 + tier: B
1574 + aliases: [lilly]
1575 + discover: { rss: false }
1576 + sensors:
1577 + - { name: press releases feed, url: "https://investor.lilly.com/rss/news-releases.xml", type: RSS, connector: rss, tier: B }
1578 + - id: novo-nordisk
1579 + name: Novo Nordisk
1580 + domain: novonordisk.com
1581 + homepage: https://www.novonordisk.com
1582 + categories: [pharma, health]
1583 + tier: B
1584 + aliases: [novo]
1585 + discover: { rss: false }
1586 + sensors:
1587 + - { name: company announcements, url: "https://www.novonordisk.com/news-and-media/news-and-ir-materials.html", type: HTML, connector: http, tier: B }
1588 + - id: gsk
1589 + name: GSK
1590 + domain: gsk.com
1591 + homepage: https://www.gsk.com
1592 + categories: [pharma, health]
1593 + tier: B
1594 + aliases: [glaxosmithkline]
1595 + discover: { rss: false }
1596 + sensors:
1597 + - { name: press releases, url: "https://www.gsk.com/en-gb/media/press-releases/", type: HTML, connector: http, tier: B }
1598 + - id: sanofi
1599 + name: Sanofi
1600 + domain: sanofi.com
1601 + homepage: https://www.sanofi.com
1602 + categories: [pharma, health]
1603 + tier: B
1604 + discover: { rss: false }
1605 + sensors:
1606 + - { name: press releases, url: "https://www.sanofi.com/en/media-room/press-releases", type: HTML, connector: http, tier: B }
1607 +
1608 + # ───────────────────────── I · Science, Research & Space ─────────────────────────
1609 + - id: arxiv
1610 + name: arXiv
1611 + domain: arxiv.org
1612 + categories: [science, ai]
1613 + tier: B
1614 + discover: { rss: false }
1615 + sensors:
1616 + - { name: cs.AI feed, url: "https://rss.arxiv.org/rss/cs.AI", type: RSS, connector: rss, tier: B, config: { maxItems: 400 } }
1617 + - { name: cs.CL feed, url: "https://rss.arxiv.org/rss/cs.CL", type: RSS, connector: rss, tier: C, config: { maxItems: 400 } }
1618 + - { name: cs.CR feed, url: "https://rss.arxiv.org/rss/cs.CR", type: RSS, connector: rss, tier: C, config: { maxItems: 300 } }
1619 + - id: nature
1620 + name: Nature
1621 + domain: nature.com
1622 + homepage: https://www.nature.com
1623 + categories: [science]
1624 + tier: B
1625 + discover: { rss: false }
1626 + sensors:
1627 + - { name: nature latest research feed, url: "https://www.nature.com/nature.rss", type: RSS, connector: rss, tier: B }
1628 + - id: science
1629 + name: Science (AAAS)
1630 + domain: science.org
1631 + homepage: https://www.science.org
1632 + categories: [science]
1633 + tier: B
1634 + aliases: [aaas, science magazine]
1635 + discover: { rss: false }
1636 + sensors:
1637 + - { name: science current issue feed, url: "https://www.science.org/action/showFeed?type=etoc&feed=rss&jc=science", type: RSS, connector: rss, tier: B }
1638 + - { name: news feed, url: "https://www.science.org/rss/news_current.xml", type: RSS, connector: rss, tier: B }
1639 + - id: nasa
1640 + name: NASA
1641 + domain: nasa.gov
1642 + homepage: https://www.nasa.gov
1643 + categories: [space, science, government]
1644 + tier: A
1645 + discover: { rss: false }
1646 + sensors:
1647 + - { name: news releases feed, url: "https://www.nasa.gov/news-release/feed/", type: RSS, connector: rss, tier: A }
1648 + - { name: breaking news feed, url: "https://www.nasa.gov/feed/", type: RSS, connector: rss, tier: B }
1649 + - id: esa
1650 + name: ESA
1651 + domain: esa.int
1652 + homepage: https://www.esa.int
1653 + categories: [space, science]
1654 + tier: B
1655 + aliases: [european space agency]
1656 + discover: { rss: false }
1657 + sensors:
1658 + - { name: top news feed, url: "https://www.esa.int/rssfeed/TopNews", type: RSS, connector: rss, tier: B }
1659 + - id: noaa
1660 + name: NOAA
1661 + domain: noaa.gov
1662 + homepage: https://www.noaa.gov
1663 + categories: [science, government]
1664 + tier: B
1665 + discover: { rss: false }
1666 + sensors:
1667 + - { name: nhc atlantic advisories, url: "https://www.nhc.noaa.gov/index-at.xml", type: RSS, connector: rss, tier: S }
1668 + - { name: space weather alerts, url: "https://services.swpc.noaa.gov/products/alerts.json", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: product_id, titleField: message, dateField: issue_datetime, maxItems: 40, compareFields: [] } }
1669 + - id: usgs
1670 + name: USGS
1671 + domain: usgs.gov
1672 + homepage: https://www.usgs.gov
1673 + categories: [science, government]
1674 + tier: A
1675 + discover: { rss: false }
1676 + sensors:
1677 + - name: significant earthquakes
1678 + url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_month.geojson"
1679 + type: REST_API
1680 + connector: jsonlist
1681 + tier: S
1682 + config: { itemsPath: features, keyField: id, titleField: properties.title, urlField: properties.url, summaryField: properties.place, compareFields: [properties.mag, properties.alert, properties.tsunami], maxItems: 100 }
1683 + - id: cern
1684 + name: CERN
1685 + domain: cern.ch
1686 + homepage: https://home.cern
1687 + categories: [science]
1688 + tier: C
1689 + discover: { rss: false }
1690 + sensors:
1691 + - { name: news feed, url: "https://home.cern/feed/", type: RSS, connector: rss, tier: C }
1692 + - id: nsf
1693 + name: NSF
1694 + domain: nsf.gov
1695 + homepage: https://www.nsf.gov
1696 + categories: [science, government]
1697 + tier: C
1698 + aliases: [national science foundation]
1699 + discover: { rss: false }
1700 + sensors:
1701 + - { name: news feed, url: "https://www.nsf.gov/rss/rss_www_news.xml", type: RSS, connector: rss, tier: C }
1702 + - id: mit
1703 + name: MIT
1704 + domain: mit.edu
1705 + homepage: https://news.mit.edu
1706 + categories: [science, ai]
1707 + tier: C
1708 + discover: { rss: false }
1709 + sensors:
1710 + - { name: news feed, url: "https://news.mit.edu/rss/feed", type: RSS, connector: rss, tier: C }
1711 + - id: stanford
1712 + name: Stanford
1713 + domain: stanford.edu
1714 + homepage: https://news.stanford.edu
1715 + categories: [science, ai]
1716 + tier: C
1717 + discover: { rss: false }
1718 + - id: harvard
1719 + name: Harvard
1720 + domain: harvard.edu
1721 + homepage: https://news.harvard.edu
1722 + categories: [science]
1723 + tier: C
1724 + discover: { rss: false }
1725 + sensors:
1726 + - { name: gazette feed, url: "https://news.harvard.edu/gazette/feed/", type: RSS, connector: rss, tier: C }
1727 + - id: berkeley
1728 + name: UC Berkeley
1729 + domain: berkeley.edu
1730 + homepage: https://news.berkeley.edu
1731 + categories: [science, ai]
1732 + tier: C
1733 + aliases: [berkeley]
1734 + discover: { rss: false }
1735 + sensors:
1736 + - { name: news feed, url: "https://news.berkeley.edu/feed/", type: RSS, connector: rss, tier: C }
1737 + - id: caltech
1738 + name: Caltech
1739 + domain: caltech.edu
1740 + homepage: https://www.caltech.edu
1741 + categories: [science]
1742 + tier: C
1743 + discover: { rss: false }
1744 + - id: max-planck
1745 + name: Max Planck Society
1746 + domain: mpg.de
1747 + homepage: https://www.mpg.de
1748 + categories: [science]
1749 + tier: C
1750 + aliases: [max planck]
1751 + discover: { rss: false }
1752 +
1753 + # ───────────────────────── J · Automotive & Transportation ─────────────────────────
1754 + - id: tesla
1755 + name: Tesla
1756 + domain: tesla.com
1757 + homepage: https://www.tesla.com
1758 + categories: [automotive, technology]
1759 + tier: A
1760 + weight: 1.2
1761 + products: [{ name: Model 3, type: product }, { name: Model Y, type: product }, { name: Cybertruck, type: product }, { name: Full Self-Driving, type: software, aliases: [fsd, autopilot] }]
1762 + discover: { rss: false, pages: false }
1763 + fallback: { scrapfly: true }
1764 + sensors:
1765 + - { name: blog, url: "https://www.tesla.com/blog", type: HTML, connector: http, tier: C }
1766 + - { name: ir press releases, url: "https://ir.tesla.com/press", type: HTML, connector: http, tier: C }
1767 + - id: ford
1768 + name: Ford
1769 + domain: ford.com
1770 + homepage: https://media.ford.com
1771 + categories: [automotive]
1772 + tier: B
1773 + discover: { rss: false }
1774 + sensors:
1775 + - { name: media news, url: "https://media.ford.com/content/fordmedia/fna/us/en/news.html", type: HTML, connector: http, tier: B }
1776 + - id: gm
1777 + name: General Motors
1778 + domain: gm.com
1779 + homepage: https://news.gm.com
1780 + categories: [automotive]
1781 + tier: B
1782 + aliases: [gm]
1783 + discover: { rss: true, pages: true }
1784 + - id: toyota
1785 + name: Toyota
1786 + domain: toyota.com
1787 + homepage: https://pressroom.toyota.com
1788 + categories: [automotive]
1789 + tier: B
1790 + discover: { rss: false }
1791 + - id: honda
1792 + name: Honda
1793 + domain: honda.com
1794 + homepage: https://hondanews.com
1795 + categories: [automotive]
1796 + tier: C
1797 + discover: { rss: false }
1798 + - id: bmw
1799 + name: BMW
1800 + domain: bmw.com
1801 + homepage: https://www.press.bmwgroup.com
1802 + categories: [automotive]
1803 + tier: C
1804 + aliases: [bmw group]
1805 + discover: { rss: false }
1806 + sensors:
1807 + - { name: press sitemap (us), url: "https://www.press.bmwgroup.com/sitemaps/sitemap_text_us_en_us.xml", type: SITEMAP, connector: sitemap, tier: C, config: { maxUrls: 1000 } }
1808 + - id: mercedes-benz
1809 + name: Mercedes-Benz
1810 + domain: mercedes-benz.com
1811 + homepage: https://group.mercedes-benz.com
1812 + categories: [automotive]
1813 + tier: C
1814 + aliases: [mercedes, daimler]
1815 + discover: { rss: false }
1816 + - id: volkswagen
1817 + name: Volkswagen
1818 + domain: volkswagen.com
1819 + homepage: https://www.volkswagen-newsroom.com
1820 + categories: [automotive]
1821 + tier: C
1822 + aliases: [vw, volkswagen group]
1823 + discover: { rss: false }
1824 + sensors:
1825 + - { name: newsroom feed, url: "https://www.volkswagen-newsroom.com/en/feeds/press-releases", type: RSS, connector: rss, tier: C }
1826 + - id: stellantis
1827 + name: Stellantis
1828 + domain: stellantis.com
1829 + homepage: https://www.stellantis.com
1830 + categories: [automotive]
1831 + tier: C
1832 + discover: { rss: false }
1833 + - id: hyundai
1834 + name: Hyundai
1835 + domain: hyundai.com
1836 + homepage: https://www.hyundainews.com
1837 + categories: [automotive]
1838 + tier: C
1839 + discover: { rss: false }
1840 + sensors:
1841 + - { name: news releases, url: "https://www.hyundainews.com/en-us/releases", type: HTML, connector: http, tier: C }
1842 + - id: nhtsa
1843 + name: NHTSA
1844 + domain: nhtsa.gov
1845 + homepage: https://www.nhtsa.gov
1846 + categories: [automotive, government]
1847 + tier: A
1848 + aliases: [national highway traffic safety administration]
1849 + discover: { rss: false }
1850 + fallback: { scrapfly: true }
1851 + sensors:
1852 + - { name: press releases, url: "https://www.nhtsa.gov/press-releases", type: HTML, connector: http, tier: C }
1853 + - id: transport-canada
1854 + name: Transport Canada
1855 + domain: tc.canada.ca
1856 + categories: [automotive, government]
1857 + tier: B
1858 + aliases: [transports canada]
1859 + discover: { rss: false }
1860 + sensors:
1861 + - { name: news feed, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=transportcanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Transport%20Canada", type: ATOM, connector: rss, tier: B }
1862 + - id: rivian
1863 + name: Rivian
1864 + domain: rivian.com
1865 + categories: [automotive]
1866 + tier: C
1867 + discover: { rss: true, pages: true }
1868 + sensors:
1869 + - { name: newsroom, url: "https://rivian.com/newsroom", type: HTML, connector: http, tier: C }
1870 + - id: lucid
1871 + name: Lucid Motors
1872 + domain: lucidmotors.com
1873 + categories: [automotive]
1874 + tier: C
1875 + aliases: [lucid]
1876 + discover: { rss: true, pages: true }
1877 + sensors:
1878 + - { name: press releases feed, url: "https://ir.lucidmotors.com/rss/news-releases.xml", type: RSS, connector: rss, tier: C }
1879 + - id: waymo
1880 + name: Waymo
1881 + domain: waymo.com
1882 + categories: [automotive, ai]
1883 + tier: B
1884 + discover: { rss: true, sitemap: true, pages: true }
1885 + sensors:
1886 + - { name: blog, url: "https://waymo.com/blog/", type: HTML, connector: http, tier: B }
1887 +
1888 + # ───────────────────────── K · Commerce, Payments & SaaS ─────────────────────────
1889 + - id: stripe
1890 + name: Stripe
1891 + domain: stripe.com
1892 + categories: [payments, developer]
1893 + tier: A
1894 + weight: 1.3
1895 + products: [{ name: Stripe API, type: API }]
1896 + discover: { rss: false }
1897 + sensors:
1898 + - { name: api changelog, url: "https://docs.stripe.com/changelog", type: HTML, connector: http, tier: A }
1899 + - { name: pricing, url: "https://stripe.com/pricing", type: HTML, connector: http, tier: A }
1900 + - { name: blog feed, url: "https://stripe.com/blog/feed.rss", type: RSS, connector: rss, tier: B }
1901 + - { name: status, url: "https://status.stripe.com/current", type: HTML, connector: http, tier: S }
1902 + - id: shopify
1903 + name: Shopify
1904 + domain: shopify.com
1905 + homepage: https://www.shopify.com
1906 + categories: [commerce, developer]
1907 + tier: B
1908 + discover: { rss: false, status: true }
1909 + sensors:
1910 + - { name: developer changelog feed, url: "https://shopify.dev/changelog/feed.xml", type: RSS, connector: rss, tier: A }
1911 + - { name: news feed, url: "https://www.shopify.com/news/feed", type: RSS, connector: rss, tier: B }
1912 + - { name: status, url: "https://www.shopifystatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
1913 + - id: paypal
1914 + name: PayPal
1915 + domain: paypal.com
1916 + homepage: https://newsroom.paypal-corp.com
1917 + categories: [payments]
1918 + tier: B
1919 + discover: { rss: false }
1920 + sensors:
1921 + - { name: newsroom feed, url: "https://newsroom.paypal-corp.com/news?pagetemplate=rss", type: RSS, connector: rss, tier: B }
1922 + - id: block
1923 + name: Block
1924 + domain: block.xyz
1925 + categories: [payments]
1926 + tier: C
1927 + aliases: [square, cash app]
1928 + discover: { rss: true, pages: true }
1929 + sensors:
1930 + - { name: square status, url: "https://www.issquareup.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
1931 + - id: visa
1932 + name: Visa
1933 + domain: visa.com
1934 + homepage: https://usa.visa.com
1935 + categories: [payments]
1936 + tier: C
1937 + discover: { rss: true, pages: true }
1938 + - id: mastercard
1939 + name: Mastercard
1940 + domain: mastercard.com
1941 + homepage: https://www.mastercard.com/news
1942 + categories: [payments]
1943 + tier: C
1944 + discover: { rss: true, pages: true }
1945 + - id: coinbase
1946 + name: Coinbase
1947 + domain: coinbase.com
1948 + homepage: https://www.coinbase.com
1949 + categories: [crypto, finance]
1950 + tier: A
1951 + discover: { rss: false }
1952 + sensors:
1953 + - { name: status, url: "https://status.coinbase.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
1954 + - id: kraken
1955 + name: Kraken
1956 + domain: kraken.com
1957 + homepage: https://www.kraken.com
1958 + categories: [crypto, finance]
1959 + tier: B
1960 + discover: { rss: false }
1961 + sensors:
1962 + - { name: status, url: "https://status.kraken.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
1963 + - { name: blog feed, url: "https://blog.kraken.com/feed", type: RSS, connector: rss, tier: B }
1964 + - id: salesforce
1965 + name: Salesforce
1966 + domain: salesforce.com
1967 + homepage: https://www.salesforce.com/news/
1968 + categories: [enterprise]
1969 + tier: B
1970 + products: [{ name: Agentforce, type: product }]
1971 + discover: { rss: false }
1972 + sensors:
1973 + - { name: news feed, url: "https://www.salesforce.com/news/feed/", type: RSS, connector: rss, tier: B }
1974 + - { name: trust status incidents, url: "https://api.status.salesforce.com/v1/incidents/active", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleTemplate: "Incident {id} — {IncidentImpacts[0].type}", summaryField: message, dateField: createdAt, compareFields: [isCore, affectsAll], maxItems: 50, urlTemplate: "https://status.salesforce.com/incidents/{key}" } }
1975 + - id: servicenow
1976 + name: ServiceNow
1977 + domain: servicenow.com
1978 + homepage: https://www.servicenow.com
1979 + categories: [enterprise]
1980 + tier: C
1981 + discover: { rss: false }
1982 +
1983 + # ───────────────────────── L · Internet Platforms & Major Information Sources ─────────────────────────
1984 + - id: reddit
1985 + name: Reddit
1986 + domain: reddit.com
1987 + homepage: https://www.reddit.com
1988 + categories: [internet]
1989 + tier: B
1990 + discover: { rss: false }
1991 + sensors:
1992 + - { name: status, url: "https://www.redditstatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S }
1993 + - { name: redditinc blog, url: "https://redditinc.com/blog", type: HTML, connector: http, tier: B }
1994 + - { name: developer changelog, url: "https://www.reddit.com/r/redditdev/new/.rss", type: RSS, connector: rss, tier: C }
1995 + - id: wikipedia
1996 + name: Wikipedia
1997 + domain: wikipedia.org
1998 + homepage: https://en.wikipedia.org
1999 + categories: [internet]
2000 + tier: B
2001 + discover: { rss: false }
2002 + sensors:
2003 + - { name: in the news (current events), url: "https://en.wikipedia.org/w/api.php?action=parse&page=Template:In_the_news&prop=text&format=json&formatversion=2", type: JSON, connector: http, tier: A, config: { ignoreKeys: [revid] } }
2004 + - { name: current events portal feed, url: "https://en.wikipedia.org/w/index.php?title=Portal:Current_events&action=history&feed=atom", type: ATOM, connector: rss, tier: B }
2005 + - id: wikimedia
2006 + name: Wikimedia Foundation
2007 + domain: wikimedia.org
2008 + homepage: https://wikimediafoundation.org
2009 + categories: [internet]
2010 + tier: C
2011 + aliases: [wikimedia]
2012 + discover: { rss: false }
2013 + sensors:
2014 + - { name: news feed, url: "https://wikimediafoundation.org/news/feed/", type: RSS, connector: rss, tier: C }
2015 + - { name: tech news, url: "https://meta.wikimedia.org/wiki/Tech/News/Latest", type: HTML, connector: http, tier: C }
2016 + - id: mozilla
2017 + name: Mozilla
2018 + domain: mozilla.org
2019 + homepage: https://www.mozilla.org
2020 + categories: [internet, developer, cyber]
2021 + tier: B
2022 + products: [{ name: Firefox, type: software }]
2023 + discover: { rss: false }
2024 + sensors:
2025 + - { name: blog feed, url: "https://blog.mozilla.org/en/feed/", type: RSS, connector: rss, tier: B }
2026 + - { name: security advisories, url: "https://www.mozilla.org/en-US/security/advisories/", type: HTML, connector: http, tier: A }
2027 + - { name: firefox releases, url: "https://www.mozilla.org/en-US/firefox/releases/", type: HTML, connector: http, tier: B }
2028 + - id: chromium
2029 + name: Chromium
2030 + domain: chromium.org
2031 + homepage: https://www.chromium.org
2032 + categories: [internet, developer]
2033 + tier: B
2034 + products: [{ name: Chromium, type: software }]
2035 + discover: { rss: false }
2036 + sensors:
2037 + - { name: chromium blog feed, url: "https://blog.chromium.org/feeds/posts/default", type: ATOM, connector: rss, tier: B }
2038 + - { name: chrome status features, url: "https://chromestatus.com/api/v0/features?milestone=", type: HTML, connector: http, tier: C }
2039 + - id: w3c
2040 + name: W3C
2041 + domain: w3.org
2042 + homepage: https://www.w3.org
2043 + categories: [standards, internet]
2044 + tier: B
2045 + aliases: [world wide web consortium]
2046 + discover: { rss: false }
2047 + sensors:
2048 + - { name: news feed, url: "https://www.w3.org/news/feed/", type: RSS, connector: rss, tier: B }
2049 + - { name: status, url: "https://status.w3.org/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A }
2050 + - id: icann
2051 + name: ICANN
2052 + domain: icann.org
2053 + homepage: https://www.icann.org
2054 + categories: [internet, standards]
2055 + tier: C
2056 + discover: { rss: false }
2057 + sensors:
2058 + - { name: announcements, url: "https://www.icann.org/en/announcements", type: HTML, connector: http, tier: C }
2059 + - id: internet-society
2060 + name: Internet Society
2061 + domain: internetsociety.org
2062 + homepage: https://www.internetsociety.org
2063 + categories: [internet]
2064 + tier: D
2065 + aliases: [isoc]
2066 + discover: { rss: false }
2067 + sensors:
2068 + - { name: news feed, url: "https://www.internetsociety.org/feed/", type: RSS, connector: rss, tier: D }
2069 + - id: ietf
2070 + name: IETF
2071 + domain: ietf.org
2072 + homepage: https://www.ietf.org
2073 + categories: [standards, internet]
2074 + tier: B
2075 + aliases: [internet engineering task force]
2076 + discover: { rss: false }
2077 + sensors:
2078 + - { name: new rfcs feed, url: "https://www.rfc-editor.org/rfcrss.xml", type: RSS, connector: rss, tier: B }
2079 + - { name: ietf blog feed, url: "https://www.ietf.org/blog/feed/", type: RSS, connector: rss, tier: C }
2080 + - id: letsencrypt
2081 + name: Let's Encrypt
2082 + domain: letsencrypt.org
2083 + categories: [internet, cyber]
2084 + tier: B
2085 + aliases: [lets encrypt, isrg]
2086 + discover: { rss: false }
2087 + sensors:
2088 + - { name: news feed, url: "https://letsencrypt.org/feed.xml", type: RSS, connector: rss, tier: B }
added deploy/README.md +44 −0
@@ -0,0 +1,44 @@
1 +# Deploying WebSensor on MacLustr
2 +
3 +WebSensor runs as three PM2 processes on one node (M4M64b), behind ngrok (`www.websensor.io`), orchestrated by
4 +`mld` from the M1M32 gateway.
5 +
6 +```
7 +Internet → www.websensor.io → ngrok → websensor-api (:8260, Fastify)
8 + ├── /api/v1/* REST + WebSocket /api/v1/live
9 + └── /* reverse proxy → websensor-web (Next.js :8261, loopback)
10 +websensor-engine (scheduler + pipeline, Prometheus :8262) → PostgreSQL 17 `websensor` + Redis (loopback)
11 +Blob store: ~/websensor-data/blobs (content-addressed, zstd) — never synced by mld (excluded `data/`).
12 +```
13 +
14 +## First deployment
15 +1. Copy `deploy/websensor.mld.json.example` to `M1M32:~/dispatch/apps/websensor.json` and fill the two secrets
16 + (`ANTHROPIC_API_KEY`, `SCRAPFLY_API_KEY`). The manifest is `0600` on the gateway; a gitignored local copy
17 + may live in `deploy/websensor.mld.json`.
18 +2. `~/Desktop/cluster-skill/mld stage ~/Desktop/Projets/apps-web/websensor websensor`
19 +3. `~/Desktop/cluster-skill/mld deploy websensor --node M4M64b`
20 + Post-sync hooks: `pnpm install`, `createdb` + migrations, registry sync, `next build`. Health: `/api/ready`
21 + (database + engine activity in the last 15 minutes), public `https://www.websensor.io/api/ready`.
22 +4. Verify: `mld status --live`, `curl https://www.websensor.io/api/v1/stats`, and a WebSocket handshake
23 + (`npx wscat -c wss://www.websensor.io/api/v1/live`).
24 +
25 +## DNS / ngrok
26 +- `www.websensor.io` is a reserved ngrok domain; the CNAME at GoDaddy points to the ngrok `*.ngrok-cname.com`
27 + target. The apex `websensor.io` should redirect to `www` (GoDaddy forwarding, or an A record to ngrok + the
28 + gateway's 301 on the apex host).
29 +- WebSocket upgrades traverse ngrok without configuration; the API sends a heartbeat every 25 s so idle
30 + connections survive ngrok's timeouts.
31 +
32 +## Operations
33 +- Logs: `mld logs websensor` or `ssh M4M64b pm2 logs websensor-engine`.
34 +- Registry edits: change `config/sources.yaml`, redeploy (sync runs in post-sync) or
35 + `ssh M4M64b "cd ~/apps/websensor && node node_modules/tsx/dist/cli.mjs apps/engine/src/cli.ts sync"`.
36 + Seed sensors removed from the YAML are disabled automatically; discovery-created sensors are kept.
37 +- Discovery: `cli.ts discover [sourceId…]`; `cli.ts probe <domain>` for a dry run.
38 +- Metrics: engine `http://127.0.0.1:8262/metrics`, API `/api/metrics` (Prometheus text). Public health dashboard:
39 + `/health` on the site, `/api/v1/health/connectors`.
40 +- Budgets: `WS_LLM_DAILY_CALL_BUDGET` (Claude calls/day), `WS_SCRAPFLY_DAILY_BUDGET` (Scrapfly calls/day, ~30
41 + credits each with anti-bot). Both counters reset at UTC midnight and are visible in `metrics_daily`.
42 +- Backups: `pg_dump websensor` + `~/websensor-data/blobs` (content-addressed; safe to rsync incrementally).
43 +- Retention: `sensor_runs` unchanged/304 rows are pruned after 14 days, all runs after 60 days; snapshots, changes
44 + and events are kept.
added deploy/websensor.mld.json.example +128 −0
@@ -0,0 +1,128 @@
1 +{
2 + "app": "websensor",
3 + "label": "WebSensor — Detect What Changed. Know Why It Matters.",
4 + "domain": "www.websensor.io",
5 + "port": 8260,
6 + "health_path": "/api/ready",
7 + "dir": "~/apps/websensor",
8 + "extra_paths": [],
9 + "sync_excludes": [
10 + "node_modules/",
11 + ".next/",
12 + ".git/",
13 + ".env",
14 + ".env.*",
15 + "!.env.example",
16 + "*.tsbuildinfo",
17 + ".turbo/",
18 + "logs/",
19 + "tmp/",
20 + "data/",
21 + "coverage/",
22 + ".DS_Store",
23 + ".claude/",
24 + "qa/",
25 + "apps/web/.next/",
26 + "apps/web/next-env.d.ts",
27 + "deploy/*.mld.json"
28 + ],
29 + "requires": {
30 + "runtimes": ["pm2", "ngrok", "node", "pnpm", "postgresql@17", "redis"],
31 + "ram_gb": 6,
32 + "ports": [8260, 8261, 8262]
33 + },
34 + "ram_mb_observed": 1500,
35 + "size_mb": 40,
36 + "placement": {
37 + "pin": "M4M64b",
38 + "prefer": null,
39 + "avoid": ["M3U96b", "M1M32"],
40 + "reason": "PostgreSQL 17 + Redis + pnpm présents ; 64 Go RAM ; M3U96a déjà chargé (25 processus PM2)"
41 + },
42 + "processes": [
43 + {
44 + "name": "websensor-engine",
45 + "manager": "pm2",
46 + "script": "/opt/homebrew/bin/node",
47 + "args": ["node_modules/tsx/dist/cli.mjs", "apps/engine/src/index.ts"],
48 + "interpreter": null,
49 + "cwd": "{{HOME}}/apps/websensor",
50 + "env": {
51 + "NODE_ENV": "production",
52 + "DATABASE_URL": "postgres://localhost:5432/websensor",
53 + "REDIS_URL": "redis://127.0.0.1:6379",
54 + "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs",
55 + "WS_SOURCES_FILE": "./config/sources.yaml",
56 + "WS_FETCH_CONCURRENCY": "16",
57 + "WS_PER_HOST_CONCURRENCY": "2",
58 + "ENGINE_METRICS_PORT": "8262",
59 + "WS_USER_AGENT": "WebSensorBot/0.1 (+https://www.websensor.io/bot; contact@websensor.io)",
60 + "ANTHROPIC_API_KEY": "<secret>",
61 + "WS_LLM_MODEL_FAST": "claude-haiku-4-5",
62 + "WS_LLM_MODEL_DEEP": "claude-opus-5",
63 + "WS_LLM_DAILY_CALL_BUDGET": "600",
64 + "WS_LLM_MIN_IMPORTANCE": "35",
65 + "WS_LLM_DEEP_MIN_IMPORTANCE": "78",
66 + "SCRAPFLY_API_KEY": "<secret>",
67 + "WS_SCRAPFLY_DAILY_BUDGET": "300",
68 + "LOG_LEVEL": "info"
69 + },
70 + "cron_restart": null,
71 + "autorestart": true,
72 + "max_memory_restart": "2G"
73 + },
74 + {
75 + "name": "websensor-api",
76 + "manager": "pm2",
77 + "script": "/opt/homebrew/bin/node",
78 + "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"],
79 + "interpreter": null,
80 + "cwd": "{{HOME}}/apps/websensor",
81 + "env": {
82 + "NODE_ENV": "production",
83 + "DATABASE_URL": "postgres://localhost:5432/websensor",
84 + "REDIS_URL": "redis://127.0.0.1:6379",
85 + "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs",
86 + "API_PORT": "8260",
87 + "API_HOST": "0.0.0.0",
88 + "WEB_URL": "http://127.0.0.1:8261",
89 + "PUBLIC_BASE_URL": "https://www.websensor.io",
90 + "CANONICAL_HOST": "www.websensor.io",
91 + "LOG_LEVEL": "info"
92 + },
93 + "cron_restart": null,
94 + "autorestart": true,
95 + "max_memory_restart": "1G"
96 + },
97 + {
98 + "name": "websensor-web",
99 + "manager": "pm2",
100 + "script": "/opt/homebrew/bin/node",
101 + "args": ["node_modules/next/dist/bin/next", "start", "-p", "8261", "-H", "127.0.0.1"],
102 + "interpreter": null,
103 + "cwd": "{{HOME}}/apps/websensor/apps/web",
104 + "env": {
105 + "NODE_ENV": "production",
106 + "API_URL": "http://127.0.0.1:8260",
107 + "NEXT_PUBLIC_SITE_URL": "https://www.websensor.io",
108 + "NEXT_TELEMETRY_DISABLED": "1"
109 + },
110 + "cron_restart": null,
111 + "autorestart": true,
112 + "max_memory_restart": "1G"
113 + }
114 + ],
115 + "ngrok": { "name": "websensor-ngrok", "url": "www.websensor.io", "port": 8260 },
116 + "launchd": [],
117 + "env_overrides": {},
118 + "hooks": {
119 + "post_sync": [
120 + "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' deps ok'",
121 + "export PATH=\"/opt/homebrew/bin:$PATH\"; psql -d postgres -Atc \"select 1 from pg_database where datname='websensor'\" | grep -q 1 || createdb websensor; DATABASE_URL=postgres://localhost:5432/websensor pnpm db:migrate 2>&1 | tail -1 && echo ' db ok'",
122 + "export PATH=\"/opt/homebrew/bin:$PATH\"; mkdir -p $HOME/websensor-data/blobs && DATABASE_URL=postgres://localhost:5432/websensor node node_modules/tsx/dist/cli.mjs apps/engine/src/cli.ts sync 2>&1 | tail -1 && echo ' registry ok'",
123 + "export PATH=\"/opt/homebrew/bin:$PATH\"; export API_URL=http://127.0.0.1:8260 NEXT_PUBLIC_SITE_URL=https://www.websensor.io NEXT_TELEMETRY_DISABLED=1; pnpm --filter @websensor/web build 2>&1 | tail -3 && echo ' web build ok'"
124 + ],
125 + "post_start": []
126 + },
127 + "notes": "v0.1.0 (2026-09-08) : gateway Fastify :8260 (REST + WS /api/v1/live + proxy → Next :8261), engine (scheduler/pipeline, métriques :8262), 200 sources / ~290 capteurs (config/sources.yaml), Postgres 17 + Redis locaux, blobs zstd dans ~/websensor-data/blobs. Secrets uniquement dans ce manifeste (M1M32)."
128 +}
added docs/ARCHITECTURE.md +49 −0
@@ -0,0 +1,49 @@
1 +# WebSensor architecture (v0.1, 2026-09-08)
2 +
3 +## Roles (one node today, separable by design)
4 +| Role | Process | Notes |
5 +|---|---|---|
6 +| gateway + API + WebSocket | `apps/api` (`websensor-api`, :8260) | Fastify; reverse-proxies the site; Redis pub/sub fan-out |
7 +| frontend | `apps/web` (`websensor-web`, :8261 loopback) | Next.js 16, server components call the API over loopback |
8 +| scheduler + workers | `apps/engine` (`websensor-engine`, metrics :8262) | several engine processes can run concurrently (`FOR UPDATE SKIP LOCKED`) |
9 +| storage | PostgreSQL 17, Redis, content-addressed blob store | Postgres = metadata/events; blobs = raw bodies, canonical text, diffs |
10 +
11 +## Data model (PostgreSQL)
12 +`sources``sensors``sensor_runs`, `snapshots``changes``events` (+ `interpretations` versions,
13 +`event_entities`, `event_clusters`), `entities` (+ `entity_aliases`, `entity_relations`, `source_entities`),
14 +`urls` + `url_history`, `watchlists`/`watchlist_items`, `alerts`/`notifications`, `connector_health`,
15 +`discovery_candidates`, `metrics_daily`, `llm_usage`. Full-text search: generated `tsvector` columns on
16 +events and entities (GIN). Migrations: `packages/db/migrations/*.sql`.
17 +
18 +## Pipeline (per sensor run)
19 +1. **Fetch** through the connector (conditional GET with ETag/Last-Modified; 304 = cheap check). SSRF policy on
20 + every hop. Anti-bot 403/429/503 → Scrapfly fallback only if the source allows it and budget remains.
21 +2. **Normalize** to a comparable representation: `text` (canonical HTML/plain text), `json` (sorted keys,
22 + ignored volatile paths) or `list` (keyed items: feed entries, sitemap URLs, incidents, releases, records).
23 +3. **Compare** canonical hashes; unchanged → done. Else store an immutable **snapshot** (raw + canonical blob).
24 +4. **Diff** against the previous canonical blob: text (paired modifications), json (path ops), list
25 + (added/removed/modified with connector-specific guards: feed window, partial sitemaps).
26 +5. **Heuristics** (`evaluateChange`): noise ratio, extracted facts (prices, versions, percents, dates), type
27 + rules + sensor/URL priors → `signal`, `eventType`, `magnitude`. A `changes` row is always written.
28 +6. If `signal ≥ WS_MEANINGFUL_SIGNAL` and extraction is trustworthy: **event candidate** — entity resolution
29 + (aliases), novelty vs the 72 h window (shingle Jaccard; near-duplicates suppressed), preliminary importance,
30 + optional **Claude interpretation** (strict JSON: type, title, summary, why it matters, observed/inferred,
31 + severity, confidence, announced?), final importance (8 stored components) + confidence, silent-change flag,
32 + clustering (shared entity/source + textual similarity within 6 h), `events` row, Redis stream + pub/sub.
33 +7. **Schedule** the next check adaptively (tier bounds, recency of change, change frequency, 304s, errors).
34 +
35 +## Latency fields
36 +`published_at` (claimed by the source) · `observed_from` (previous snapshot) · `detected_at` (fetch) ·
37 +`processed_at` · `published_to_feed_at`; `detection_latency_ms` (only when published < 7 days before detection)
38 +and `processing_latency_ms`.
39 +
40 +## Real-time
41 +Engine → Redis stream `ws:events` (durable, MAXLEN ~20k) + channel `ws:live`. Gateway subscribes once and routes
42 +to clients by channel: `events:global`, `events:breaking` (≥ 80), `events:silent`, `events:<ai|cyber|finance|
43 +health|government|science|products|infrastructure>`, `type:<event_type>`, `entity:<id>`, `source:<id>`,
44 +`watchlist:<id>` (server-side matching of the watchlist items).
45 +
46 +## Not in v0.1 (explicitly)
47 +Browser rendering (Playwright), Firecrawl, PDF diffing, OpenSearch (Postgres FTS is used), MinIO (fs blob store
48 +with the same interface), accounts/email delivery (watchlists and alerts are keyed by an anonymous owner token),
49 +embeddings (shingle similarity is used for novelty/clustering), MCP server, customer webhooks.
added docs/connectors/discovery.md +8 −0
@@ -0,0 +1,8 @@
1 +# Discovery engine
2 +For a domain: `robots.txt` (Sitemap: lines, image/video sitemaps skipped, news first, ≤ 8), homepage
3 +`<link rel="alternate">` feeds and links to `status.*`/`*.statuspage.io`, well-known feed paths (`/feed`,
4 +`/rss.xml`, `/atom.xml`, `/changelog/feed`, `/releases.atom`…), well-known sitemap paths, and (if `pages`)
5 +HEAD probes of `/changelog`, `/news`, `/pricing`, `/security`… Every candidate is **fetched and parsed**; only
6 +parseable feeds/sitemaps/statuspages are stored in `discovery_candidates` and promoted (≤ 3 feeds, 1 sitemap,
7 +1 statuspage, pricing/changelog/security pages) according to the source's `discover` flags. Re-run every
8 +`WS_DISCOVERY_INTERVAL_DAYS` (7). CLI: `cli.ts probe <domain>` (dry run), `cli.ts discover [sourceId…]`.
added docs/connectors/github.md +10 −0
@@ -0,0 +1,10 @@
1 +# Connector: GitHub (`github`)
2 +**Purpose**: releases, tags, commits and security advisories of public repositories.
3 +**Retrieval**: prefers the unauthenticated Atom feeds (`/releases.atom`, `/tags.atom`, `/commits/<branch>.atom`)
4 +which are not subject to the 60 req/h REST limit; the REST API (`/repos/{owner}/{repo}/security-advisories`,
5 +optional `GITHUB_TOKEN`) only for advisories. Conditional GET honoured.
6 +**Config**: `{ repo: "owner/name", kind: releases|tags|commits|advisories, branch? }`.
7 +**Normalization**: keyed list by entry id / GHSA id; `compareFields: title` (releases/tags) or
8 +`state, severity, title` (advisories). **Events**: `repository_release`, `software_release`, `security_advisory`.
9 +**Verified**: 2026-09-08 on 20 repositories (anthropics/*, openai/openai-python, vercel/next.js,
10 +kubernetes/kubernetes, python/cpython, hashicorp/terraform, CVEProject/cvelistV5…).
added docs/connectors/http.md +16 −0
@@ -0,0 +1,16 @@
1 +# Connector: Generic HTTP (`http`)
2 +**Purpose**: HTML pages (pricing, news lists, security bulletins, changelogs), JSON documents, plain text and
3 +HEAD-only header monitoring. **Sensor types**: HTML, JSON, XML, HTTP_HEADERS, FILE.
4 +**Retrieval**: conditional GET (ETag / If-Modified-Since), gzip/br, manual redirects (≤ 5, SSRF-checked per hop),
5 +12 MB limit, 25 s timeout, HTTP/2 with automatic HTTP/1.1 pin on NGHTTP2 stream errors, one browser-UA retry on
6 +403 or TLS/connection resets, Scrapfly fallback if the source allows it.
7 +**Normalization**: `canonicalizeHtml` (removes script/style/nav/footer/aside/cookie chrome, comments, tracking
8 +params; scrubs clock times, "x minutes ago", hashes/UUIDs, tokens, visitor counters) → canonical text, headings,
9 +links, structure counts, meta dates; JSON → sorted-key canonical form (`jsonPath`, `ignoreKeys` config).
10 +Thin pages (< 40 chars, no headings: JS shells, interstitials) get extraction confidence 0.3 and never produce
11 +events (flip protection).
12 +**Config**: `keepChrome`, `jsonPath`, `ignoreKeys`, `accept`, `headers`, `method: HEAD`, `renderJs` (Scrapfly).
13 +**Known quirks**: Akamai/Cloudflare bot management returns 403 or resets the connection for unknown UAs (handled
14 +by the browser-UA retry, else Scrapfly). Client-rendered pages produce thin canonical text — prefer feeds/APIs.
15 +**Tests**: `packages/core/src/core.test.ts` (canonicalization, diff, heuristics). **Verified**: 2026-09-08 on
16 +~85 HTML/JSON sensors (Anthropic news/pricing, Stripe changelog, Apple system status, Wikipedia ITN…).
added docs/connectors/jsonlist.md +12 −0
@@ -0,0 +1,12 @@
1 +# Connector: JSON API list (`jsonlist`)
2 +**Purpose**: keyed records from official JSON APIs. Used for CISA KEV, NVD CVE 2.0 (`{now-3h}` window),
3 +Federal Register documents (rules, proposed rules, presidential documents), ClinicalTrials.gov v2, USGS
4 +significant earthquakes (GeoJSON), Have I Been Pwned breaches, MSRC security updates, Google Cloud / Firebase /
5 +Workspace incidents, NOAA SWPC alerts, Salesforce Trust incidents.
6 +**Config**: `itemsPath` (dot path to the array, `""` for a root array), `keyField`, `titleField` or
7 +`titleTemplate` (`{a.b}` placeholders), `urlField`/`urlTemplate` (`{key}`), `summaryField` (arrays and
8 +`{lang,value}` description lists are flattened), `dateField`, `compareFields`, `maxItems`, `headers`,
9 +`noConditional` (for windowed queries), `timeoutMs`. URL placeholders `{now}`, `{now-2h}`, `{now-30d}`.
10 +**Rate limits**: NVD 5 req/30 s without key (sensor interval 15 min); Federal Register 1000/h; ClinicalTrials
11 +none documented; KEV is a static file (~1.7 MB, ETag).
12 +**Verified**: 2026-09-08 (14 sensors). **Tests**: `tests/fixtures/kev.json`.
added docs/connectors/rss.md +15 −0
@@ -0,0 +1,15 @@
1 +# Connector: RSS / Atom / JSON Feed (`rss`)
2 +**Purpose**: newsrooms, blogs, changelogs, advisories, government news APIs (Canada.ca Atom), releases.
3 +**Sensor types**: RSS, ATOM. **Retrieval**: conditional GET; Accept prefers feed types.
4 +**Parsing**: RSS 2.0, Atom, RSS 1.0/RDF, JSON Feed 1.x; tolerant to BOM/leading junk, CDATA, HTML in
5 +descriptions (stripped), missing GUIDs (fallback link → hash of title+summary), `feedburner:origLink`,
6 +odd RFC-822 zones. Items deduplicated by key.
7 +**Normalization**: keyed list (`key` = GUID/id/link), `compareFields: title, summary` (an edited item is a
8 +"modified" change). Sensor `state.seenKeys` (last 2000) makes items "new" only when never seen; items leaving the
9 +window are never "removed"; items older than 14 days are ignored as backfills. `publishedAt` = newest item.
10 +**Config**: `maxItems` (default 100). **Failure handling**: HTML instead of a feed → `parse_error`
11 +(`html_not_feed`), health DEGRADED after repeated failures, ERROR after 5.
12 +**Known quirks**: Blogger feeds embed full posts (use `feeds/posts/summary?max-results=N`); the SEC EDGAR current
13 +filings Atom is extremely high volume (not monitored by default); many press feeds are behind bot management.
14 +**Tests**: `packages/connectors/src/connectors.test.ts` with `tests/fixtures/rss-before.xml`, `rss-after.xml`,
15 +`atom.xml`. **Verified**: 2026-09-08 on ~140 live feeds (AWS What's New, Fed press, CISA advisories, arXiv…).
added docs/connectors/scrapfly.md +8 −0
@@ -0,0 +1,8 @@
1 +# Fallback: Scrapfly (`scrapfly`)
2 +Acquisition hierarchy step 14. Used only when the direct fetch returned 403/429/503 **and** the source has
3 +`fallback: { scrapfly: true }` **and** the daily budget (`WS_SCRAPFLY_DAILY_BUDGET`, default 300 calls) is not
4 +exhausted. Request: `GET https://api.scrapfly.io/scrape?key&url&asp=true&render_js=false&country=us&retry=true`;
5 +response `result.content` is fed to the normal connector normalization; `fetch_method = API`,
6 +`x-websensor-via: scrapfly`. Cost observed: ~30 credits per call with anti-scraping protection (residential proxy
7 ++ auto browser). Sources enabled at launch: OpenAI (pricing page), xAI, Tesla, NHTSA, NIH — all tier C (hourly).
8 +Tested live 2026-09-08 (tesla.com/blog: 200, 1.25 MB).
added docs/connectors/sitemap.md +12 −0
@@ -0,0 +1,12 @@
1 +# Connector: Sitemap (`sitemap`)
2 +**Purpose**: page-level discovery — new URLs, removed URLs, `lastmod` bumps — including news sitemaps.
3 +**Sensor type**: SITEMAP. **Retrieval**: conditional GET, 30 MB limit, gzip transparently decompressed
4 +(magic-byte check), sitemap index followed (`maxChildren`, default 6; newest first when `lastmod` is present).
5 +**Normalization**: keyed list by URL, `compareFields: lastmod`; sorted newest first; `include`/`exclude` regexes;
6 +`maxUrls` (default 5000). A > 50 % shrink versus the previous snapshot is treated as a partial response (no
7 +removals reported).
8 +**Events**: `page_created` (new URL), `page_removed` (URL gone, only after confirmations), `documentation_change`
9 +etc. via heuristics on titles/URLs.
10 +**Known quirks**: huge multilingual indexes (BMW: 100+ children) — choose a specific child sitemap; image/video
11 +sitemaps are skipped by discovery. **Tests**: fixtures `tests/fixtures/sitemap.xml`. **Verified**: 2026-09-08
12 +(anthropic.com sitemap 524 URLs, BMW press sitemap).
added docs/connectors/statuspage.md +12 −0
@@ -0,0 +1,12 @@
1 +# Connector: Statuspage (`statuspage`)
2 +**Purpose**: incidents, scheduled maintenances and component degradations on Atlassian Statuspage instances
3 +(`/api/v2/summary.json`). **Sensor type**: STATUSPAGE, tier S by default.
4 +**Normalization**: keyed list — `incident:<id>`, `maintenance:<id>` (title = name — status, latest update body,
5 +impact, created/updated), `component:<id>` for non-operational components, plus an `overall` indicator item.
6 +`compareFields: status, title, updatedAt` → incident created / updated / resolved, maintenance scheduled,
7 +component degraded/restored. Sensor `extra` keeps the indicator and counts.
8 +**Non-Atlassian pages**: Google-style JSON (`incidents.json`: Google Cloud, Firebase, Workspace) and Salesforce
9 +Trust are handled by the `jsonlist` connector; Heroku's own API by `http` (JSON mode); status.io/instatus pages
10 +are not supported (they return HTML) — use their RSS/history feed when available.
11 +**Verified**: 2026-09-08 on 23 status pages (OpenAI, Anthropic, Cloudflare, GitHub, Vercel, Netlify, Coinbase,
12 +Kraken, Reddit, MongoDB, Elastic, HashiCorp, Groq…). **Tests**: `tests/fixtures/statuspage.json`.
added package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "websensor",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "WebSensor.io — Detect What Changed. Know Why It Matters. A global sensor network for the changing Web.",
6 + "type": "module",
7 + "packageManager": "pnpm@11.1.2",
8 + "engines": {
9 + "node": ">=22.15"
10 + },
11 + "scripts": {
12 + "typecheck": "pnpm -r --parallel run typecheck",
13 + "test": "pnpm -r run test",
14 + "build": "pnpm --filter @websensor/web run build",
15 + "db:generate": "pnpm --filter @websensor/db run generate",
16 + "db:migrate": "pnpm --filter @websensor/db run migrate",
17 + "db:seed": "pnpm --filter @websensor/db run seed",
18 + "dev:api": "pnpm --filter @websensor/api run dev",
19 + "dev:engine": "pnpm --filter @websensor/engine run dev",
20 + "dev:web": "pnpm --filter @websensor/web run dev",
21 + "start:api": "pnpm --filter @websensor/api run start",
22 + "start:engine": "pnpm --filter @websensor/engine run start",
23 + "start:web": "pnpm --filter @websensor/web run start"
24 + },
25 + "devDependencies": {
26 + "@types/node": "^24.0.0",
27 + "tsx": "^4.20.0",
28 + "typescript": "^5.9.3",
29 + "vitest": "^3.2.0"
30 + }
31 +}
added packages/connectors/package.json +23 −0
@@ -0,0 +1,23 @@
1 +{
2 + "name": "@websensor/connectors",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": { ".": "./src/index.ts" },
9 + "scripts": {
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run --passWithNoTests"
12 + },
13 + "dependencies": {
14 + "@websensor/core": "workspace:*",
15 + "fast-xml-parser": "^5.2.0",
16 + "undici": "^7.10.0"
17 + },
18 + "devDependencies": {
19 + "@types/node": "^24.0.0",
20 + "typescript": "^5.9.3",
21 + "vitest": "^3.2.0"
22 + }
23 +}
added packages/connectors/src/connectors.test.ts +89 −0
@@ -0,0 +1,89 @@
1 +import { readFileSync } from "node:fs";
2 +import { dirname, join } from "node:path";
3 +import { fileURLToPath } from "node:url";
4 +import { describe, expect, it } from "vitest";
5 +import type { SensorEndpoint } from "@websensor/core";
6 +import { expandUrl, JsonListConnector } from "./jsonlist";
7 +import { parseFeed, RssConnector } from "./rss";
8 +import { parseSitemap } from "./sitemap";
9 +import { StatuspageConnector } from "./statuspage";
10 +import { NormalizeError } from "./types";
11 +
12 +const here = dirname(fileURLToPath(import.meta.url));
13 +const fx = (name: string): string => readFileSync(join(here, "..", "..", "..", "tests", "fixtures", name), "utf8");
14 +const obs = (sensorId: string, body: string, contentType = "application/xml") => ({ sensorId, url: "https://example.com", fetchedAt: new Date("2026-09-08T12:00:00Z"), notModified: false, body: Buffer.from(body), meta: { status: 200, url: "https://example.com", finalUrl: "https://example.com", contentType, contentLength: body.length, etag: null, lastModified: null, durationMs: 10, redirects: 0, method: "GET" as const, headers: {} } });
15 +const ep = (type: SensorEndpoint["type"], connector: string, config: Record<string, unknown> = {}, state: Record<string, unknown> | null = null): SensorEndpoint => ({ id: "t", sourceId: "acme", name: "t", url: "https://example.com", type, tier: "A", connector, config, state });
16 +
17 +describe("feed parsing", () => {
18 + it("parses RSS 2.0 with GUIDs, dates and HTML descriptions", () => {
19 + const f = parseFeed(fx("rss-before.xml"));
20 + expect(f.kind).toBe("rss");
21 + expect(f.items).toHaveLength(2);
22 + expect(f.items[0]).toMatchObject({ key: "post-2", title: "Second post", url: "https://acme.com/blog/second" });
23 + expect(f.items[0]!.summary).toBe("Body of second post with bold text.");
24 + expect(f.items[0]!.publishedAt).toBe("2026-09-07T10:00:00.000Z");
25 + });
26 + it("parses Atom and JSON Feed", () => {
27 + const a = parseFeed(fx("atom.xml"));
28 + expect(a.kind).toBe("atom");
29 + expect(a.items[0]).toMatchObject({ key: "tag:acme.com,2026:rel-2.1.0", title: "v2.1.0", url: "https://github.com/acme/tool/releases/tag/v2.1.0" });
30 + const j = parseFeed('{"version":"https://jsonfeed.org/version/1.1","title":"J","items":[{"id":"1","url":"https://a.com/1","title":"One","date_published":"2026-09-01T00:00:00Z"}]}');
31 + expect(j.kind).toBe("jsonfeed");
32 + expect(j.items[0]!.publishedAt).toBe("2026-09-01T00:00:00.000Z");
33 + });
34 + it("rejects HTML masquerading as a feed", async () => {
35 + const c = new RssConnector();
36 + await expect(c.normalize(ep("RSS", "rss"), obs("t", "<!doctype html><html><body>nope</body></html>", "text/html"))).rejects.toBeInstanceOf(NormalizeError);
37 + });
38 + it("normalizes to a keyed list and tracks seen keys across runs", async () => {
39 + const c = new RssConnector();
40 + const n1 = await c.normalize(ep("RSS", "rss"), obs("t", fx("rss-before.xml")));
41 + expect(n1.mode).toBe("list");
42 + expect(n1.items?.map((i) => i.key)).toEqual(["post-2", "post-1"]);
43 + const n2 = await c.normalize(ep("RSS", "rss", {}, n1.state ?? null), obs("t", fx("rss-after.xml")));
44 + expect(n2.canonicalHash).not.toBe(n1.canonicalHash);
45 + expect((n2.state?.seenKeys as string[]).sort()).toEqual(["post-1", "post-2", "post-3"]);
46 + expect(n2.publishedAt?.toISOString()).toBe("2026-09-08T09:30:00.000Z");
47 + });
48 +});
49 +
50 +describe("sitemap parsing", () => {
51 + it("parses url sets, indexes and news metadata", () => {
52 + const s = parseSitemap(fx("sitemap.xml"));
53 + expect(s.kind).toBe("urlset");
54 + expect(s.entries).toHaveLength(3);
55 + expect(s.entries[0]).toMatchObject({ url: "https://acme.com/news/launch", lastmod: "2026-09-08" });
56 + expect(s.entries[0]!.title).toBe("Acme launches Widget 2");
57 + const idx = parseSitemap('<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://acme.com/s1.xml</loc></sitemap></sitemapindex>');
58 + expect(idx.kind).toBe("sitemapindex");
59 + expect(idx.children).toEqual(["https://acme.com/s1.xml"]);
60 + expect(() => parseSitemap("<html></html>")).toThrow(NormalizeError);
61 + });
62 +});
63 +
64 +describe("statuspage", () => {
65 + it("emits incidents, maintenances, degraded components and the overall indicator", async () => {
66 + const c = new StatuspageConnector();
67 + const n = await c.normalize(ep("STATUSPAGE", "statuspage"), obs("t", fx("statuspage.json"), "application/json"));
68 + const keys = n.items!.map((i) => i.key);
69 + expect(keys).toEqual(expect.arrayContaining(["incident:inc1", "maintenance:m1", "component:c2", "overall"]));
70 + expect(keys).not.toContain("component:c1"); // operational components are not tracked
71 + expect(n.items!.find((i) => i.key === "incident:inc1")).toMatchObject({ status: "investigating", impact: "major" });
72 + expect(n.extra).toMatchObject({ indicator: "major", activeIncidents: 1, degradedComponents: 1 });
73 + });
74 +});
75 +
76 +describe("json list connector", () => {
77 + it("maps KEV-style records with templates and dot paths", async () => {
78 + const c = new JsonListConnector();
79 + const n = await c.normalize(ep("REST_API", "jsonlist", { itemsPath: "vulnerabilities", keyField: "cveID", titleTemplate: "{cveID} — {vendorProject} {product}", summaryField: "shortDescription", dateField: "dateAdded", urlTemplate: "https://kev.example/{key}", compareFields: ["knownRansomwareCampaignUse"] }), obs("t", fx("kev.json"), "application/json"));
80 + expect(n.items).toHaveLength(2);
81 + expect(n.items![0]).toMatchObject({ key: "CVE-2026-0001", title: "CVE-2026-0001 — Acme Router", url: "https://kev.example/CVE-2026-0001", knownRansomwareCampaignUse: "Known" });
82 + expect(n.publishedAt?.toISOString()).toBe("2026-09-08T00:00:00.000Z");
83 + await expect(c.normalize(ep("REST_API", "jsonlist", { itemsPath: "nope" }), obs("t", "{}", "application/json"))).rejects.toThrow(/not an array/);
84 + });
85 + it("expands time placeholders", () => {
86 + const u = expandUrl("https://api.example/cves?start={now-2h}&end={now}");
87 + expect(u).toMatch(/start=\d{4}-\d{2}-\d{2}T\d{2}%3A\d{2}%3A\d{2}\.000&end=/);
88 + });
89 +});
added packages/connectors/src/discovery.ts +150 −0
@@ -0,0 +1,150 @@
1 +import { newId, type SensorType } from "@websensor/core";
2 +import { httpFetch } from "./fetcher";
3 +import { parseFeed } from "./rss";
4 +import { parseSitemap } from "./sitemap";
5 +
6 +/**
7 + * Discovery engine: for a domain, probe well-known paths, robots.txt sitemaps, HTML
8 + * `<link rel="alternate">` feeds and known status providers; validate each candidate by
9 + * actually fetching and parsing it. Nothing is assumed from booleans in the registry.
10 + */
11 +export interface DiscoveredEndpoint {
12 + url: string;
13 + type: SensorType;
14 + connector: string;
15 + evidence: string;
16 + /** rough information value 0–1 used to rank candidates */
17 + value: number;
18 + itemCount?: number;
19 + title?: string;
20 +}
21 +
22 +const FEED_PATHS = ["/feed", "/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/feed/", "/blog/feed", "/blog/rss.xml", "/blog/feed.xml", "/news/feed", "/news/rss", "/news/rss.xml", "/newsroom/rss", "/rss/news", "/feeds/all.atom.xml", "/en/feed", "/changelog/feed", "/changelog.rss", "/changelog/rss.xml", "/releases.atom", "/security/feed", "/blog/index.xml"];
23 +const SITEMAP_PATHS = ["/sitemap.xml", "/sitemap_index.xml", "/sitemap-index.xml", "/sitemaps/sitemap.xml", "/news-sitemap.xml", "/sitemap/news.xml"];
24 +const PAGE_PATHS: [string, string][] = [
25 + ["/changelog", "changelog"],
26 + ["/news", "news"],
27 + ["/newsroom", "news"],
28 + ["/blog", "blog"],
29 + ["/releases", "releases"],
30 + ["/security", "security"],
31 + ["/pricing", "pricing"],
32 + ["/status", "status"],
33 + ["/docs", "docs"],
34 +];
35 +
36 +export async function discoverDomain(domain: string, opts: { probePages?: boolean; sensorIdForLogs?: string } = {}): Promise<DiscoveredEndpoint[]> {
37 + const base = `https://${domain}`;
38 + const found = new Map<string, DiscoveredEndpoint>();
39 + const id = opts.sensorIdForLogs ?? `discover_${domain}`;
40 +
41 + const add = (e: DiscoveredEndpoint): void => {
42 + const k = e.url.replace(/\/$/, "");
43 + if (!found.has(k) || (found.get(k)!.value < e.value)) found.set(k, e);
44 + };
45 +
46 + // robots.txt → Sitemap: lines
47 + const robots = await httpFetch(id, `${base}/robots.txt`, { timeoutMs: 12_000, maxBytes: 512 * 1024 });
48 + const sitemapsFromRobots: string[] = [];
49 + if (robots.body && robots.meta.status === 200) {
50 + for (const line of robots.body.toString("utf8").split(/\r?\n/)) {
51 + const m = line.match(/^\s*sitemap:\s*(\S+)/i);
52 + if (m) sitemapsFromRobots.push(m[1]!);
53 + }
54 + }
55 +
56 + // Homepage: <link rel="alternate"> and links to status/changelog
57 + const home = await httpFetch(id, base + "/", { timeoutMs: 15_000, maxBytes: 3 * 1024 * 1024 });
58 + const homeHtml = home.body && home.meta.status < 400 ? home.body.toString("utf8") : "";
59 + const alternates = [...homeHtml.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)]
60 + .map((m) => m[0])
61 + .filter((tag) => /application\/(rss|atom)\+xml|application\/feed\+json/i.test(tag))
62 + .map((tag) => tag.match(/href=["']([^"']+)["']/i)?.[1])
63 + .filter((h): h is string => Boolean(h))
64 + .map((h) => safeAbs(h, home.meta.finalUrl || base))
65 + .filter((h): h is string => Boolean(h));
66 + const statusLinks = [...homeHtml.matchAll(/href=["'](https?:\/\/(?:status|statuspage|health)\.[^"'\s]+|https?:\/\/[^"'\s]*\.statuspage\.io[^"'\s]*)["']/gi)].map((m) => m[1]!);
67 +
68 + const feedCandidates = [...new Set([...alternates, ...FEED_PATHS.map((p) => base + p)])];
69 + const sitemapCandidates = [...new Set([...sitemapsFromRobots.filter((u) => !/image|video/i.test(u)).sort((a, b) => Number(/news/i.test(b)) - Number(/news/i.test(a))).slice(0, 8), ...SITEMAP_PATHS.map((p) => base + p)])];
70 +
71 + await parallel(feedCandidates, 6, async (url) => {
72 + const o = await httpFetch(id, url, { timeoutMs: 12_000, maxBytes: 4 * 1024 * 1024 });
73 + if (!o.body || o.meta.status !== 200) return;
74 + const text = o.body.toString("utf8");
75 + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 400))) return;
76 + try {
77 + const f = parseFeed(text, o.meta.finalUrl);
78 + if (!f.items.length) return;
79 + const dated = f.items.filter((i) => i.publishedAt).length / f.items.length;
80 + add({ url: o.meta.finalUrl || url, type: f.kind === "atom" ? "ATOM" : "RSS", connector: "rss", evidence: alternates.includes(url) ? "link[rel=alternate]" : "well-known path", value: 0.8 + 0.2 * dated, itemCount: f.items.length, title: f.title });
81 + } catch {
82 + // not a feed
83 + }
84 + });
85 +
86 + await parallel(sitemapCandidates, 4, async (url) => {
87 + const o = await httpFetch(id, url, { timeoutMs: 15_000, maxBytes: 20 * 1024 * 1024 });
88 + if (!o.body || o.meta.status !== 200) return;
89 + const text = o.body.toString("utf8");
90 + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 400))) return;
91 + try {
92 + const s = parseSitemap(text);
93 + const n = s.kind === "sitemapindex" ? s.children.length : s.entries.length;
94 + if (!n) return;
95 + const lastmods = s.entries.filter((e) => e.lastmod).length;
96 + add({ url: o.meta.finalUrl || url, type: "SITEMAP", connector: "sitemap", evidence: sitemapsFromRobots.includes(url) ? "robots.txt" : "well-known path", value: 0.5 + (s.entries.length ? 0.3 * (lastmods / s.entries.length) : 0.2) + (/news/i.test(url) ? 0.2 : 0), itemCount: n });
97 + } catch {
98 + // not a sitemap
99 + }
100 + });
101 +
102 + for (const s of statusLinks) {
103 + try {
104 + const u = new URL(s);
105 + const api = `${u.origin}/api/v2/summary.json`;
106 + const o = await httpFetch(id, api, { timeoutMs: 12_000, accept: "application/json" });
107 + if (o.body && o.meta.status === 200 && /"incidents"/.test(o.body.toString("utf8").slice(0, 20_000))) add({ url: api, type: "STATUSPAGE", connector: "statuspage", evidence: `linked from homepage (${u.host})`, value: 0.95 });
108 + } catch {
109 + // ignore
110 + }
111 + }
112 +
113 + if (opts.probePages) {
114 + await parallel(PAGE_PATHS, 4, async ([path, kind]) => {
115 + const url = base + path;
116 + const o = await httpFetch(id, url, { method: "HEAD", timeoutMs: 10_000 });
117 + if (o.meta.status === 200 || o.meta.status === 405) add({ url, type: "HTML", connector: "http", evidence: `HEAD ${o.meta.status}`, value: kind === "pricing" || kind === "changelog" || kind === "security" ? 0.6 : 0.4 });
118 + });
119 + }
120 +
121 + return [...found.values()].sort((a, b) => b.value - a.value);
122 +}
123 +
124 +function safeAbs(h: string, base: string): string | null {
125 + try {
126 + const u = new URL(h, base);
127 + return u.protocol.startsWith("http") ? u.toString() : null;
128 + } catch {
129 + return null;
130 + }
131 +}
132 +
133 +async function parallel<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
134 + let i = 0;
135 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
136 + while (i < items.length) {
137 + const item = items[i++]!;
138 + try {
139 + await fn(item);
140 + } catch {
141 + // swallow: discovery is best effort
142 + }
143 + }
144 + });
145 + await Promise.all(workers);
146 +}
147 +
148 +export function candidateId(): string {
149 + return newId("cand");
150 +}
added packages/connectors/src/fetcher.ts +238 −0
@@ -0,0 +1,238 @@
1 +import { gunzipSync } from "node:zlib";
2 +import { Agent, fetch as undiciFetch, type Dispatcher } from "undici";
3 +import { assertUrlAllowed, safeLookup, UrlPolicyError, type FetchMeta, type Observation } from "@websensor/core";
4 +
5 +/**
6 + * Generic HTTP fetcher: conditional GET (ETag / Last-Modified), manual redirect handling
7 + * with SSRF validation on every hop, size/time limits, and a dispatcher whose DNS lookup
8 + * only returns policy-approved addresses (DNS rebinding protection).
9 + */
10 +
11 +export interface FetchOptions {
12 + method?: "GET" | "HEAD";
13 + etag?: string | null;
14 + lastModified?: string | null;
15 + headers?: Record<string, string>;
16 + timeoutMs?: number;
17 + maxBytes?: number;
18 + maxRedirects?: number;
19 + accept?: string;
20 + userAgent?: string;
21 +}
22 +
23 +export class FetchError extends Error {
24 + constructor(
25 + public readonly code: string,
26 + message: string,
27 + ) {
28 + super(message);
29 + this.name = "FetchError";
30 + }
31 +}
32 +
33 +const DEFAULT_UA = process.env.WS_USER_AGENT ?? "WebSensorBot/0.1 (+https://www.websensor.io/bot; contact@websensor.io)";
34 +const DEFAULT_TIMEOUT = 25_000;
35 +const DEFAULT_MAX_BYTES = 12 * 1024 * 1024;
36 +
37 +let agent: Dispatcher | null = null;
38 +let agentH1: Dispatcher | null = null;
39 +export function getDispatcher(h1only = false): Dispatcher {
40 + if (h1only) {
41 + if (!agentH1) agentH1 = new Agent({ connect: { lookup: safeLookup as never, timeout: 10_000 }, connections: 32, pipelining: 1, keepAliveTimeout: 15_000, headersTimeout: 25_000, bodyTimeout: 30_000, allowH2: false });
42 + return agentH1;
43 + }
44 + if (!agent) {
45 + agent = new Agent({
46 + connect: { lookup: safeLookup as never, timeout: 10_000 },
47 + connections: 64,
48 + pipelining: 1,
49 + keepAliveTimeout: 15_000,
50 + headersTimeout: 20_000,
51 + bodyTimeout: 30_000,
52 + allowH2: true,
53 + });
54 + }
55 + return agent;
56 +}
57 +
58 +/** Hosts where HTTP/2 misbehaved (NGHTTP2 stream errors, header timeouts) — pinned to HTTP/1.1. */
59 +const h1Hosts = new Set<string>();
60 +
61 +export async function closeDispatcher(): Promise<void> {
62 + if (agent) await agent.close();
63 + if (agentH1) await agentH1.close();
64 + agent = null;
65 + agentH1 = null;
66 +}
67 +
68 +/** A conventional browser identity, used only as a second attempt when the bot UA is refused (403). */
69 +export const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15";
70 +
71 +function headerMap(h: Headers): Record<string, string> {
72 + const out: Record<string, string> = {};
73 + h.forEach((v, k) => {
74 + if (/^(content-type|content-length|etag|last-modified|cache-control|server|x-ratelimit-[a-z-]+|retry-after|date|age|via|cf-ray|x-cache|link)$/i.test(k)) out[k.toLowerCase()] = v;
75 + });
76 + return out;
77 +}
78 +
79 +export async function httpFetch(sensorId: string, urlStr: string, opts: FetchOptions = {}): Promise<Observation> {
80 + const started = Date.now();
81 + const method = opts.method ?? "GET";
82 + const maxRedirects = opts.maxRedirects ?? 5;
83 + let current = urlStr;
84 + let redirects = 0;
85 + const headers: Record<string, string> = {
86 + "user-agent": opts.userAgent ?? DEFAULT_UA,
87 + accept: opts.accept ?? "application/rss+xml, application/atom+xml, application/json, application/xml, text/html;q=0.9, text/plain;q=0.8, */*;q=0.5",
88 + "accept-language": "en-US,en;q=0.8,fr;q=0.5",
89 + "accept-encoding": "gzip, deflate, br",
90 + ...opts.headers,
91 + };
92 + if (opts.etag) headers["if-none-match"] = opts.etag;
93 + if (opts.lastModified) headers["if-modified-since"] = opts.lastModified;
94 +
95 + const fail = (code: string, message: string): Observation => ({
96 + sensorId,
97 + url: urlStr,
98 + fetchedAt: new Date(),
99 + notModified: false,
100 + error: { code, message },
101 + meta: { status: 0, url: urlStr, finalUrl: current, contentType: null, contentLength: 0, etag: null, lastModified: null, durationMs: Date.now() - started, redirects, method, headers: {} },
102 + });
103 +
104 + for (;;) {
105 + try {
106 + await assertUrlAllowed(current);
107 + } catch (e) {
108 + return fail("ssrf_blocked", e instanceof UrlPolicyError ? e.message : String(e));
109 + }
110 + const ac = new AbortController();
111 + const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT);
112 + let res: Response;
113 + try {
114 + const host = new URL(current).hostname;
115 + res = (await undiciFetch(current, { method, headers, redirect: "manual", signal: ac.signal, dispatcher: getDispatcher(h1Hosts.has(host)) } as never)) as unknown as Response;
116 + } catch (e) {
117 + clearTimeout(timer);
118 + const msg = e instanceof Error ? `${e.name}: ${e.message}${(e as { cause?: Error }).cause ? " — " + String((e as { cause?: Error }).cause?.message ?? (e as { cause?: unknown }).cause) : ""}` : String(e);
119 + if (/NGHTTP2|HTTP\/2/i.test(msg)) {
120 + const host = new URL(current).hostname;
121 + if (!h1Hosts.has(host)) {
122 + h1Hosts.add(host);
123 + continue; // retry this hop over HTTP/1.1
124 + }
125 + }
126 + const code = ac.signal.aborted ? "timeout" : /ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg) ? "dns" : /EBLOCKED|blocked/i.test(msg) ? "ssrf_blocked" : /CERT|TLS|SSL|certificate/i.test(msg) ? "tls" : /ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ETIMEDOUT|socket/i.test(msg) ? "connection" : "fetch_failed";
127 + return fail(code, msg);
128 + }
129 +
130 + if ([301, 302, 303, 307, 308].includes(res.status)) {
131 + clearTimeout(timer);
132 + const loc = res.headers.get("location");
133 + if (!loc) return fail("redirect_without_location", `HTTP ${res.status} without Location`);
134 + if (++redirects > maxRedirects) return fail("too_many_redirects", `More than ${maxRedirects} redirects`);
135 + try {
136 + current = new URL(loc, current).toString();
137 + } catch {
138 + return fail("bad_redirect", `Invalid Location header: ${loc}`);
139 + }
140 + // conditional headers only apply to the original resource
141 + delete headers["if-none-match"];
142 + delete headers["if-modified-since"];
143 + continue;
144 + }
145 +
146 + const meta: FetchMeta = {
147 + status: res.status,
148 + url: urlStr,
149 + finalUrl: current,
150 + contentType: res.headers.get("content-type"),
151 + contentLength: 0,
152 + etag: res.headers.get("etag"),
153 + lastModified: res.headers.get("last-modified"),
154 + durationMs: 0,
155 + redirects,
156 + method,
157 + headers: headerMap(res.headers),
158 + };
159 +
160 + if (res.status === 304) {
161 + clearTimeout(timer);
162 + meta.durationMs = Date.now() - started;
163 + return { sensorId, url: urlStr, fetchedAt: new Date(), meta, notModified: true };
164 + }
165 +
166 + if (method === "HEAD") {
167 + clearTimeout(timer);
168 + meta.durationMs = Date.now() - started;
169 + meta.contentLength = Number(res.headers.get("content-length") ?? 0);
170 + return { sensorId, url: urlStr, fetchedAt: new Date(), meta, notModified: false };
171 + }
172 +
173 + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
174 + const declared = Number(res.headers.get("content-length") ?? 0);
175 + if (declared > maxBytes) {
176 + clearTimeout(timer);
177 + return fail("too_large", `Content-Length ${declared} exceeds ${maxBytes}`);
178 + }
179 + const chunks: Uint8Array[] = [];
180 + let total = 0;
181 + try {
182 + const reader = res.body?.getReader();
183 + if (reader) {
184 + for (;;) {
185 + const { done, value } = await reader.read();
186 + if (done) break;
187 + total += value.byteLength;
188 + if (total > maxBytes) {
189 + await reader.cancel();
190 + clearTimeout(timer);
191 + return fail("too_large", `Body exceeded ${maxBytes} bytes`);
192 + }
193 + chunks.push(value);
194 + }
195 + }
196 + } catch (e) {
197 + clearTimeout(timer);
198 + return fail(ac.signal.aborted ? "timeout" : "body_read_failed", e instanceof Error ? e.message : String(e));
199 + }
200 + clearTimeout(timer);
201 + let body = Buffer.concat(chunks);
202 + // Some servers send gzip'd sitemaps as application/octet-stream without content-encoding.
203 + if (body.length > 2 && body[0] === 0x1f && body[1] === 0x8b) {
204 + try {
205 + body = gunzipSync(body);
206 + } catch {
207 + // keep as-is
208 + }
209 + }
210 + meta.contentLength = body.length;
211 + meta.durationMs = Date.now() - started;
212 + return { sensorId, url: urlStr, fetchedAt: new Date(), meta, body, notModified: false };
213 + }
214 +}
215 +
216 +/** Retry-aware wrapper for transient failures (5xx, connection, timeout). */
217 +export async function httpFetchWithRetry(sensorId: string, url: string, opts: FetchOptions = {}, retries = 1): Promise<Observation> {
218 + let last: Observation | null = null;
219 + for (let attempt = 0; attempt <= retries; attempt++) {
220 + const obs = await httpFetch(sensorId, url, opts);
221 + last = obs;
222 + if (!obs.error && obs.meta.status === 403 && !opts.userAgent) {
223 + // Some WAFs refuse unknown bot identities on public feeds; try once as a regular browser.
224 + const alt = await httpFetch(sensorId, url, { ...opts, userAgent: BROWSER_UA, headers: { ...opts.headers, "sec-fetch-mode": "navigate", "sec-fetch-dest": "document", "upgrade-insecure-requests": "1" } });
225 + if (alt.error || alt.meta.status === 403) return obs;
226 + return alt;
227 + }
228 + if (obs.error && ["timeout", "connection", "fetch_failed"].includes(obs.error.code) && !opts.userAgent && attempt === 0) {
229 + // Some edges (Akamai) silently reset unknown bot identities at the TLS/HTTP layer.
230 + const alt = await httpFetch(sensorId, url, { ...opts, userAgent: BROWSER_UA });
231 + if (!alt.error) return alt;
232 + }
233 + const transient = obs.error ? ["timeout", "connection", "body_read_failed", "fetch_failed"].includes(obs.error.code) : obs.meta.status >= 500 && obs.meta.status !== 501;
234 + if (!transient) return obs;
235 + if (attempt < retries) await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
236 + }
237 + return last!;
238 +}
added packages/connectors/src/github.ts +64 −0
@@ -0,0 +1,64 @@
1 +import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { NormalizeError, type WebSensorConnector } from "./types";
4 +import { parseFeed } from "./rss";
5 +
6 +/**
7 + * GitHub connector. Prefers the unauthenticated Atom feeds (`/releases.atom`, `/tags.atom`,
8 + * `/commits/<branch>.atom`) which are not subject to the 60 req/h REST limit; uses the REST
9 + * API (with optional GITHUB_TOKEN) only for `security advisories`.
10 + * Config: { repo: "owner/name", kind: "releases" | "tags" | "commits" | "advisories", branch?: string }
11 + */
12 +export class GitHubConnector implements WebSensorConnector {
13 + mode = "list" as const;
14 + metadata(): ConnectorMetadata {
15 + return { key: "github", name: "GitHub", sensorTypes: ["GITHUB_RELEASE", "GITHUB_REPO"], description: "Releases, tags, commits (Atom) and security advisories (REST)", version: "1.0.0" };
16 + }
17 + private urlFor(endpoint: SensorEndpoint): { url: string; api: boolean } {
18 + const cfg = endpoint.config as { repo?: string; kind?: string; branch?: string };
19 + if (!cfg.repo) return { url: endpoint.url, api: /api\.github\.com/.test(endpoint.url) };
20 + switch (cfg.kind ?? "releases") {
21 + case "tags":
22 + return { url: `https://github.com/${cfg.repo}/tags.atom`, api: false };
23 + case "commits":
24 + return { url: `https://github.com/${cfg.repo}/commits/${cfg.branch ?? "main"}.atom`, api: false };
25 + case "advisories":
26 + return { url: `https://api.github.com/repos/${cfg.repo}/security-advisories?per_page=30`, api: true };
27 + default:
28 + return { url: `https://github.com/${cfg.repo}/releases.atom`, api: false };
29 + }
30 + }
31 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
32 + const { url, api } = this.urlFor(endpoint);
33 + const headers: Record<string, string> = {};
34 + if (api) {
35 + headers.accept = "application/vnd.github+json";
36 + headers["x-github-api-version"] = "2022-11-28";
37 + if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
38 + }
39 + const obs = await httpFetchWithRetry(endpoint.id, url, { etag: endpoint.etag, lastModified: endpoint.lastModified, headers, accept: api ? "application/vnd.github+json" : "application/atom+xml, application/xml;q=0.9" });
40 + return { ...obs, url: endpoint.url };
41 + }
42 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
43 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
44 + const text = obs.body.toString("utf8");
45 + const { api } = this.urlFor(endpoint);
46 + if (api) {
47 + let arr: Record<string, unknown>[];
48 + try {
49 + arr = JSON.parse(text) as Record<string, unknown>[];
50 + } catch {
51 + throw new NormalizeError("bad_json", "GitHub API response is not JSON");
52 + }
53 + if (!Array.isArray(arr)) throw new NormalizeError("bad_shape", String((arr as Record<string, unknown>).message ?? "Unexpected GitHub API response"));
54 + const items = arr.map((a) => ({ key: String(a.ghsa_id ?? a.id), title: `${String(a.ghsa_id ?? "")} ${String(a.summary ?? "")}`.trim(), url: String(a.html_url ?? ""), summary: String(a.description ?? "").slice(0, 1000), severity: String(a.severity ?? ""), cve: String(a.cve_id ?? ""), publishedAt: a.published_at ? new Date(String(a.published_at)).toISOString() : null, state: String(a.state ?? "") }));
55 + const canonical = items.map((i) => `${i.key}\t${i.state}\t${i.severity}`).join("\n");
56 + return { mode: "list", items, compareFields: ["state", "severity", "title"], rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: items[0]?.publishedAt ? new Date(items[0].publishedAt) : null, extractionConfidence: 1 };
57 + }
58 + const feed = parseFeed(text, obs.meta.finalUrl);
59 + const items = feed.items.slice(0, 60);
60 + const canonical = items.map((i) => `${i.key}\t${i.title}\t${i.updatedAt ?? ""}`).join("\n");
61 + const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1);
62 + return { mode: "list", items, compareFields: ["title"], title: feed.title, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { feedKind: feed.kind } };
63 + }
64 +}
added packages/connectors/src/http.ts +100 −0
@@ -0,0 +1,100 @@
1 +import { canonicalizeHtml, canonicalizeText, canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { NormalizeError, type WebSensorConnector } from "./types";
4 +
5 +/**
6 + * Generic HTTP connector: HTML pages (canonical text), plain text and JSON documents.
7 + * Config: { keepChrome?: boolean, selector?: string, accept?: string, headers?: {} }
8 + */
9 +export class HttpConnector implements WebSensorConnector {
10 + metadata(): ConnectorMetadata {
11 + return { key: "http", name: "Generic HTTP", sensorTypes: ["HTML", "JSON", "XML", "HTTP_HEADERS", "FILE"], description: "Conditional GET + canonical extraction for HTML/JSON/text", version: "1.0.0" };
12 + }
13 +
14 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
15 + const cfg = endpoint.config as { accept?: string; headers?: Record<string, string>; method?: "GET" | "HEAD"; timeoutMs?: number };
16 + return httpFetchWithRetry(endpoint.id, endpoint.url, {
17 + method: cfg.method ?? "GET",
18 + etag: endpoint.etag,
19 + lastModified: endpoint.lastModified,
20 + accept: cfg.accept ?? (endpoint.type === "JSON" ? "application/json, */*;q=0.5" : undefined),
21 + headers: cfg.headers,
22 + timeoutMs: cfg.timeoutMs,
23 + });
24 + }
25 +
26 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
27 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
28 + const ct = (obs.meta.contentType ?? "").toLowerCase();
29 + const text = obs.body.toString("utf8");
30 + const cfg = endpoint.config as { keepChrome?: boolean; jsonPath?: string; ignoreKeys?: string[] };
31 +
32 + if (endpoint.type === "HTTP_HEADERS" || obs.meta.method === "HEAD") {
33 + const h = { ...obs.meta.headers };
34 + delete h.date;
35 + delete h.age;
36 + delete h["cf-ray"];
37 + const j = canonicalJson(h);
38 + return { mode: "json", json: h, rawHash: sha256(j), canonicalHash: sha256(j), semanticHash: simhash(j), extractionConfidence: 1 };
39 + }
40 +
41 + if (endpoint.type === "JSON" || ct.includes("json") || /^\s*[[{]/.test(text.slice(0, 50))) {
42 + let parsed: unknown;
43 + try {
44 + parsed = JSON.parse(text);
45 + } catch {
46 + if (endpoint.type === "JSON") throw new NormalizeError("bad_json", "Response is not valid JSON");
47 + parsed = undefined;
48 + }
49 + if (parsed !== undefined) {
50 + let node: unknown = parsed;
51 + if (cfg.jsonPath) node = getPath(node, cfg.jsonPath);
52 + if (cfg.ignoreKeys?.length) node = stripKeys(node, new Set(cfg.ignoreKeys));
53 + const j = canonicalJson(node);
54 + return { mode: "json", json: node as object | null, rawHash: sha256(text), canonicalHash: sha256(j), semanticHash: simhash(j), extractionConfidence: 1 };
55 + }
56 + }
57 +
58 + if (ct.includes("html") || /<\s*(!doctype|html|body|div|p)\b/i.test(text.slice(0, 4000))) {
59 + const c = canonicalizeHtml(text, obs.meta.finalUrl, { keepChrome: cfg.keepChrome });
60 + if (c.text.length < 40 && c.headings.length === 0) {
61 + // Probably a JS shell, a challenge page, or an interstitial: low extraction confidence.
62 + return { mode: "text", text: c.text, title: c.title, headings: c.headings, links: c.links, rawHash: c.rawHash, canonicalHash: c.canonicalHash, semanticHash: c.semanticHash, extractionConfidence: 0.3, publishedAt: parseMetaDate(c.meta), extra: { structure: c.structure, meta: c.meta, thin: true } };
63 + }
64 + return { mode: "text", text: c.text, title: c.title, headings: c.headings, links: c.links, rawHash: c.rawHash, canonicalHash: c.canonicalHash, semanticHash: c.semanticHash, extractionConfidence: 0.75, publishedAt: parseMetaDate(c.meta), extra: { structure: c.structure, meta: c.meta } };
65 + }
66 +
67 + const t = canonicalizeText(text);
68 + return { mode: "text", text: t.text, rawHash: t.rawHash, canonicalHash: t.canonicalHash, semanticHash: t.semanticHash, extractionConfidence: 0.9 };
69 + }
70 +}
71 +
72 +function parseMetaDate(meta: Record<string, string>): Date | null {
73 + const s = meta["article:modified_time"] ?? meta["article:published_time"] ?? meta["last-modified"];
74 + if (!s) return null;
75 + const d = new Date(s);
76 + return Number.isNaN(d.getTime()) ? null : d;
77 +}
78 +
79 +export function getPath(obj: unknown, path: string): unknown {
80 + let cur = obj;
81 + for (const part of path.split(".").filter(Boolean)) {
82 + if (cur === null || cur === undefined) return undefined;
83 + const m = part.match(/^(\w+)?\[(\d+)\]$/);
84 + if (m) {
85 + if (m[1]) cur = (cur as Record<string, unknown>)[m[1]];
86 + cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined;
87 + } else cur = (cur as Record<string, unknown>)[part];
88 + }
89 + return cur;
90 +}
91 +
92 +function stripKeys(v: unknown, keys: Set<string>): unknown {
93 + if (Array.isArray(v)) return v.map((x) => stripKeys(x, keys));
94 + if (v && typeof v === "object") {
95 + const out: Record<string, unknown> = {};
96 + for (const [k, val] of Object.entries(v as Record<string, unknown>)) if (!keys.has(k)) out[k] = stripKeys(val, keys);
97 + return out;
98 + }
99 + return v;
100 +}
added packages/connectors/src/index.ts +38 −0
@@ -0,0 +1,38 @@
1 +import type { WebSensorConnector } from "./types";
2 +import { HttpConnector } from "./http";
3 +import { RssConnector } from "./rss";
4 +import { SitemapConnector } from "./sitemap";
5 +import { StatuspageConnector } from "./statuspage";
6 +import { GitHubConnector } from "./github";
7 +import { JsonListConnector } from "./jsonlist";
8 +
9 +export * from "./types";
10 +export * from "./fetcher";
11 +export * from "./http";
12 +export * from "./rss";
13 +export * from "./sitemap";
14 +export * from "./statuspage";
15 +export * from "./github";
16 +export * from "./jsonlist";
17 +export * from "./discovery";
18 +export * from "./xml";
19 +export * from "./scrapfly";
20 +
21 +const REGISTRY: Record<string, WebSensorConnector> = {
22 + http: new HttpConnector(),
23 + rss: new RssConnector(),
24 + sitemap: new SitemapConnector(),
25 + statuspage: new StatuspageConnector(),
26 + github: new GitHubConnector(),
27 + jsonlist: new JsonListConnector(),
28 +};
29 +
30 +export function getConnector(key: string): WebSensorConnector {
31 + const c = REGISTRY[key];
32 + if (!c) throw new Error(`Unknown connector "${key}"`);
33 + return c;
34 +}
35 +
36 +export function listConnectors(): WebSensorConnector[] {
37 + return Object.values(REGISTRY);
38 +}
added packages/connectors/src/jsonlist.ts +89 −0
@@ -0,0 +1,89 @@
1 +import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { getPath } from "./http";
4 +import { NormalizeError, type WebSensorConnector } from "./types";
5 +
6 +/**
7 + * Generic JSON API list connector — covers official public APIs that return a list of
8 + * records: CISA KEV, Federal Register, ClinicalTrials.gov v2, USGS GeoJSON, Have I Been
9 + * Pwned breaches, NVD CVE 2.0, JSON changelogs…
10 + *
11 + * Config:
12 + * itemsPath: "vulnerabilities" | "results" | "studies" | "features" | "" (dot path to the array)
13 + * keyField: "cveID" | "document_number" | "protocolSection.identificationModule.nctId" | "id"
14 + * titleField, urlField, summaryField, dateField: dot paths inside each item
15 + * compareFields: string[] (fields whose change counts as an update)
16 + * urlTemplate: "https://nvd.nist.gov/vuln/detail/{key}"
17 + * headers: {} (e.g. User-Agent contact required by SEC)
18 + * maxItems: 200
19 + */
20 +export class JsonListConnector implements WebSensorConnector {
21 + mode = "list" as const;
22 + metadata(): ConnectorMetadata {
23 + return { key: "jsonlist", name: "JSON API list", sensorTypes: ["REST_API", "JSON"], description: "Keyed records from an official JSON API (KEV, Federal Register, ClinicalTrials, USGS, HIBP, NVD…)", version: "1.0.0" };
24 + }
25 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
26 + const cfg = endpoint.config as { headers?: Record<string, string>; url?: string; timeoutMs?: number; noConditional?: boolean };
27 + // Support {date} placeholders for APIs that require a window (NVD).
28 + const url = expandUrl(cfg.url ?? endpoint.url);
29 + const obs = await httpFetchWithRetry(endpoint.id, url, { etag: cfg.noConditional ? null : endpoint.etag, lastModified: cfg.noConditional ? null : endpoint.lastModified, headers: cfg.headers, accept: "application/json, */*;q=0.5", timeoutMs: cfg.timeoutMs ?? 40_000, maxBytes: 40 * 1024 * 1024 });
30 + return { ...obs, url: endpoint.url };
31 + }
32 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
33 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
34 + const text = obs.body.toString("utf8");
35 + let root: unknown;
36 + try {
37 + root = JSON.parse(text);
38 + } catch {
39 + throw new NormalizeError("bad_json", "API response is not JSON");
40 + }
41 + const cfg = endpoint.config as { itemsPath?: string; keyField?: string; titleField?: string; urlField?: string; summaryField?: string; dateField?: string; compareFields?: string[]; urlTemplate?: string; maxItems?: number; titleTemplate?: string };
42 + const arr = cfg.itemsPath ? getPath(root, cfg.itemsPath) : root;
43 + if (!Array.isArray(arr)) throw new NormalizeError("bad_shape", `itemsPath "${cfg.itemsPath ?? ""}" is not an array`);
44 + const items = (arr as Record<string, unknown>[]).slice(0, cfg.maxItems ?? 300).map((it) => {
45 + const key = str(getPath(it, cfg.keyField ?? "id"));
46 + const title = cfg.titleTemplate ? fill(cfg.titleTemplate, it) : str(getPath(it, cfg.titleField ?? "title"));
47 + const url = cfg.urlTemplate ? cfg.urlTemplate.replace("{key}", encodeURIComponent(key)) : str(getPath(it, cfg.urlField ?? "url"));
48 + const summary = str(getPath(it, cfg.summaryField ?? "summary")).slice(0, 1200);
49 + const dateRaw = str(getPath(it, cfg.dateField ?? "date"));
50 + const d = dateRaw ? new Date(dateRaw) : null;
51 + const out: Record<string, unknown> & { key: string } = { key, title, url, summary, publishedAt: d && !Number.isNaN(d.getTime()) ? d.toISOString() : null };
52 + for (const f of cfg.compareFields ?? []) out[f] = getPath(it, f);
53 + return out;
54 + });
55 + const compareFields = cfg.compareFields ?? ["title"];
56 + const canonical = items.map((i) => `${i.key}\t${compareFields.map((f) => JSON.stringify(i[f] ?? "")).join("\t")}`).join("\n");
57 + const newest = items.map((i) => i.publishedAt as string | null).filter((x): x is string => Boolean(x)).sort().at(-1);
58 + return { mode: "list", items, compareFields, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => String(i.title)).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { count: items.length } };
59 + }
60 +}
61 +
62 +function str(v: unknown): string {
63 + if (v === null || v === undefined) return "";
64 + if (typeof v === "string") return v;
65 + if (typeof v === "number" || typeof v === "boolean") return String(v);
66 + if (Array.isArray(v)) return v.map(str).filter(Boolean).join(", ");
67 + if (typeof v === "object") {
68 + const o = v as Record<string, unknown>;
69 + if (typeof o.value === "string") return o.value;
70 + if (Array.isArray(o.descriptions)) return str((o.descriptions as Record<string, unknown>[]).find((d) => d.lang === "en")?.value ?? o.descriptions[0]);
71 + }
72 + return JSON.stringify(v).slice(0, 400);
73 +}
74 +
75 +function fill(tpl: string, it: Record<string, unknown>): string {
76 + return tpl.replace(/\{([^}]+)\}/g, (_, p: string) => str(getPath(it, p)));
77 +}
78 +
79 +/** `{now-2h}` / `{now}` placeholders → ISO-8601 (no millis, NVD-compatible). */
80 +export function expandUrl(url: string): string {
81 + return url.replace(/\{now(?:-(\d+)([hmd]))?\}/g, (_, n: string | undefined, u: string | undefined) => {
82 + const d = new Date();
83 + if (n && u) {
84 + const ms = Number(n) * (u === "h" ? 3600e3 : u === "m" ? 60e3 : 86400e3);
85 + d.setTime(d.getTime() - ms);
86 + }
87 + return encodeURIComponent(d.toISOString().replace(/\.\d{3}Z$/, ".000"));
88 + });
89 +}
added packages/connectors/src/rss.ts +161 −0
@@ -0,0 +1,161 @@
1 +import { sha256, simhash, stripTrackingParams, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { NormalizeError, type WebSensorConnector } from "./types";
4 +import { asArray, parseDate, parseXml, stripHtml, textOf } from "./xml";
5 +
6 +export type FeedItem = {
7 + key: string;
8 + title: string;
9 + url: string;
10 + summary: string;
11 + publishedAt: string | null;
12 + updatedAt: string | null;
13 + author?: string;
14 + categories?: string[];
15 + [k: string]: unknown;
16 +};
17 +
18 +export interface ParsedFeed {
19 + kind: "rss" | "atom" | "rdf" | "jsonfeed";
20 + title: string;
21 + link: string;
22 + items: FeedItem[];
23 +}
24 +
25 +/** RSS 2.0 / Atom / RSS 1.0 (RDF) / JSON Feed parser tolerant to common malformations. */
26 +export function parseFeed(text: string, baseUrl?: string): ParsedFeed {
27 + const trimmed = text.trimStart();
28 + if (trimmed.startsWith("{")) return parseJsonFeed(trimmed);
29 + const doc = parseXml(text);
30 + if (doc.rss) {
31 + const ch = ((doc.rss as Record<string, unknown>).channel ?? {}) as Record<string, unknown>;
32 + const items = asArray(ch.item as unknown[]).map((raw) => rssItem(raw as Record<string, unknown>, baseUrl));
33 + return { kind: "rss", title: stripHtml(textOf(ch.title)), link: textOf(ch.link), items: dedupe(items) };
34 + }
35 + if (doc.feed) {
36 + const f = doc.feed as Record<string, unknown>;
37 + const items = asArray(f.entry as unknown[]).map((raw) => atomEntry(raw as Record<string, unknown>, baseUrl));
38 + const link = asArray(f.link as unknown[])
39 + .map((l) => l as Record<string, unknown>)
40 + .find((l) => !l["@_rel"] || l["@_rel"] === "alternate");
41 + return { kind: "atom", title: stripHtml(textOf(f.title)), link: textOf(link?.["@_href"]), items: dedupe(items) };
42 + }
43 + if (doc["rdf:RDF"]) {
44 + const r = doc["rdf:RDF"] as Record<string, unknown>;
45 + const ch = (r.channel ?? {}) as Record<string, unknown>;
46 + const items = asArray(r.item as unknown[]).map((raw) => rssItem(raw as Record<string, unknown>, baseUrl));
47 + return { kind: "rdf", title: stripHtml(textOf(ch.title)), link: textOf(ch.link), items: dedupe(items) };
48 + }
49 + throw new NormalizeError("not_a_feed", "Document is neither RSS, Atom, RDF nor JSON Feed");
50 +}
51 +
52 +function rssItem(raw: Record<string, unknown>, baseUrl?: string): FeedItem {
53 + const guid = textOf(raw.guid);
54 + let link = textOf(raw.link);
55 + if (!link && typeof raw.link === "object") link = textOf((raw.link as Record<string, unknown>)["@_href"]);
56 + if (!link && guid.startsWith("http")) link = guid;
57 + if (!link && raw["feedburner:origLink"]) link = textOf(raw["feedburner:origLink"]);
58 + const url = safeUrl(link, baseUrl);
59 + const title = stripHtml(textOf(raw.title));
60 + const summary = stripHtml(textOf(raw.description) || textOf(raw["content:encoded"]) || textOf(raw.summary)).slice(0, 1200);
61 + const pub = parseDate(raw.pubDate) ?? parseDate(raw["dc:date"]) ?? parseDate(raw.published);
62 + const key = guid || url || sha256(title + summary).slice(0, 24);
63 + const categories = asArray(raw.category as unknown[])
64 + .map((c) => stripHtml(textOf(c)))
65 + .filter(Boolean);
66 + return { key, title, url, summary, publishedAt: pub?.toISOString() ?? null, updatedAt: null, author: stripHtml(textOf(raw.author) || textOf(raw["dc:creator"])) || undefined, categories };
67 +}
68 +
69 +function atomEntry(raw: Record<string, unknown>, baseUrl?: string): FeedItem {
70 + const id = textOf(raw.id);
71 + const links = asArray(raw.link as unknown[]).map((l) => l as Record<string, unknown>);
72 + const alt = links.find((l) => !l["@_rel"] || l["@_rel"] === "alternate") ?? links[0];
73 + const link = textOf(alt?.["@_href"]) || (id.startsWith("http") ? id : "");
74 + const url = safeUrl(link, baseUrl);
75 + const title = stripHtml(textOf(raw.title));
76 + const summary = stripHtml(textOf(raw.summary) || textOf(raw.content)).slice(0, 1200);
77 + const pub = parseDate(raw.published) ?? parseDate(raw.issued);
78 + const upd = parseDate(raw.updated);
79 + const author = raw.author && typeof raw.author === "object" ? stripHtml(textOf((raw.author as Record<string, unknown>).name)) : stripHtml(textOf(raw.author));
80 + const categories = asArray(raw.category as unknown[])
81 + .map((c) => textOf((c as Record<string, unknown>)["@_term"]) || textOf((c as Record<string, unknown>)["@_label"]))
82 + .filter(Boolean);
83 + return { key: id || url || sha256(title + summary).slice(0, 24), title, url, summary, publishedAt: (pub ?? upd)?.toISOString() ?? null, updatedAt: upd?.toISOString() ?? null, author: author || undefined, categories };
84 +}
85 +
86 +function parseJsonFeed(text: string): ParsedFeed {
87 + let j: Record<string, unknown>;
88 + try {
89 + j = JSON.parse(text) as Record<string, unknown>;
90 + } catch {
91 + throw new NormalizeError("bad_json", "Invalid JSON Feed");
92 + }
93 + const items = asArray(j.items as Record<string, unknown>[]).map((it) => {
94 + const url = String(it.url ?? it.external_url ?? "");
95 + const title = stripHtml(String(it.title ?? ""));
96 + const summary = stripHtml(String(it.summary ?? it.content_text ?? it.content_html ?? "")).slice(0, 1200);
97 + return { key: String(it.id ?? url), title, url, summary, publishedAt: it.date_published ? new Date(String(it.date_published)).toISOString() : null, updatedAt: it.date_modified ? new Date(String(it.date_modified)).toISOString() : null } satisfies FeedItem;
98 + });
99 + return { kind: "jsonfeed", title: String(j.title ?? ""), link: String(j.home_page_url ?? ""), items: dedupe(items) };
100 +}
101 +
102 +function safeUrl(link: string, baseUrl?: string): string {
103 + if (!link) return "";
104 + try {
105 + return stripTrackingParams(new URL(link, baseUrl).toString());
106 + } catch {
107 + return link;
108 + }
109 +}
110 +
111 +function dedupe(items: FeedItem[]): FeedItem[] {
112 + const seen = new Set<string>();
113 + const out: FeedItem[] = [];
114 + for (const it of items) {
115 + if (seen.has(it.key)) continue;
116 + seen.add(it.key);
117 + out.push(it);
118 + }
119 + return out;
120 +}
121 +
122 +/**
123 + * RSS/Atom connector. Sensor state keeps the set of seen item keys so items that scroll
124 + * out of the feed window are not reported as "removed".
125 + */
126 +export class RssConnector implements WebSensorConnector {
127 + mode = "list" as const;
128 + metadata(): ConnectorMetadata {
129 + return { key: "rss", name: "RSS / Atom", sensorTypes: ["RSS", "ATOM"], description: "Feed parser with GUID deduplication and update detection", version: "1.0.0" };
130 + }
131 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
132 + return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, application/feed+json, application/json;q=0.8, */*;q=0.5" });
133 + }
134 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
135 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
136 + const text = obs.body.toString("utf8");
137 + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 500))) throw new NormalizeError("html_not_feed", "Endpoint returned HTML instead of a feed");
138 + const feed = parseFeed(text, obs.meta.finalUrl);
139 + const cfg = endpoint.config as { maxItems?: number };
140 + const items = feed.items.slice(0, cfg.maxItems ?? 100).map((it) => ({ ...it, key: it.key }));
141 + const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1);
142 + // Items comparison: key is the GUID; title/summary changes count as modifications.
143 + const canonical = items.map((i) => `${i.key}\t${i.title}\t${sha256(i.summary).slice(0, 12)}`).join("\n");
144 + const seen = new Set<string>(Array.isArray(endpoint.state?.seenKeys) ? (endpoint.state!.seenKeys as string[]) : []);
145 + for (const i of items) seen.add(i.key);
146 + const seenKeys = [...seen].slice(-2000);
147 + return {
148 + mode: "list",
149 + items,
150 + compareFields: ["title", "summary"],
151 + title: feed.title,
152 + rawHash: sha256(text),
153 + canonicalHash: sha256(canonical),
154 + semanticHash: simhash(items.map((i) => i.title).join("\n")),
155 + publishedAt: newest ? new Date(newest) : null,
156 + state: { seenKeys, feedKind: feed.kind },
157 + extractionConfidence: 1,
158 + extra: { feedKind: feed.kind, itemCount: items.length },
159 + };
160 + }
161 +}
added packages/connectors/src/scrapfly.ts +68 −0
@@ -0,0 +1,68 @@
1 +import type { Observation } from "@websensor/core";
2 +import { assertUrlAllowed } from "@websensor/core";
3 +
4 +/**
5 + * Scrapfly fallback (acquisition hierarchy step 14). Used only when ordinary HTTP failed with
6 + * an anti-bot response (403/429/503) AND the source registry allows it (`fallback.scrapfly`).
7 + * Budgeted per day; never used for the baseline crawl of ordinary pages.
8 + * Docs: https://scrapfly.io/docs/scrape-api/getting-started — GET /scrape?key&url&asp&render_js
9 + * Response: { result: { content, status_code, success, response_headers, url }, context: { cost } }
10 + */
11 +let dayKey = "";
12 +let used = 0;
13 +
14 +export function scrapflyBudget(): { used: number; limit: number; day: string } {
15 + roll();
16 + return { used, limit: Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300), day: dayKey };
17 +}
18 +
19 +function roll(): void {
20 + const today = new Date().toISOString().slice(0, 10);
21 + if (dayKey !== today) {
22 + dayKey = today;
23 + used = 0;
24 + }
25 +}
26 +
27 +export function scrapflyAvailable(): boolean {
28 + roll();
29 + return Boolean(process.env.SCRAPFLY_API_KEY) && used < Number(process.env.WS_SCRAPFLY_DAILY_BUDGET ?? 300);
30 +}
31 +
32 +export async function scrapflyFetch(sensorId: string, url: string, opts: { renderJs?: boolean; country?: string } = {}): Promise<Observation> {
33 + const started = Date.now();
34 + const key = process.env.SCRAPFLY_API_KEY;
35 + const fail = (code: string, message: string): Observation => ({ sensorId, url, fetchedAt: new Date(), notModified: false, error: { code, message }, meta: { status: 0, url, finalUrl: url, contentType: null, contentLength: 0, etag: null, lastModified: null, durationMs: Date.now() - started, redirects: 0, method: "API", headers: {} } });
36 + if (!key) return fail("scrapfly_unavailable", "SCRAPFLY_API_KEY not configured");
37 + if (!scrapflyAvailable()) return fail("scrapfly_budget", "daily Scrapfly budget exhausted");
38 + try {
39 + await assertUrlAllowed(url);
40 + } catch (e) {
41 + return fail("ssrf_blocked", (e as Error).message);
42 + }
43 + used++;
44 + const q = new URLSearchParams({ key, url, asp: "true", render_js: opts.renderJs ? "true" : "false", country: opts.country ?? "us", retry: "true" });
45 + const ac = new AbortController();
46 + const timer = setTimeout(() => ac.abort(), 90_000);
47 + try {
48 + const res = await fetch(`https://api.scrapfly.io/scrape?${q.toString()}`, { signal: ac.signal, headers: { accept: "application/json" } });
49 + const json = (await res.json()) as { result?: { content?: string; status_code?: number; success?: boolean; reason?: string; response_headers?: Record<string, string>; url?: string; error?: { message?: string } }; message?: string; code?: string };
50 + const r = json.result;
51 + if (!res.ok || !r) return fail("scrapfly_error", `${res.status} ${json.message ?? json.code ?? "no result"}`);
52 + if (!r.success) return fail("scrapfly_failed", `${r.status_code ?? 0} ${r.reason ?? r.error?.message ?? "upstream failed"}`);
53 + const body = Buffer.from(r.content ?? "", "utf8");
54 + const h = Object.fromEntries(Object.entries(r.response_headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]));
55 + return {
56 + sensorId,
57 + url,
58 + fetchedAt: new Date(),
59 + notModified: false,
60 + body,
61 + meta: { status: r.status_code ?? 200, url, finalUrl: r.url ?? url, contentType: h["content-type"] ?? null, contentLength: body.length, etag: null, lastModified: h["last-modified"] ?? null, durationMs: Date.now() - started, redirects: 0, method: "API", headers: { ...h, "x-websensor-via": "scrapfly" } },
62 + };
63 + } catch (e) {
64 + return fail(ac.signal.aborted ? "timeout" : "scrapfly_error", (e as Error).message);
65 + } finally {
66 + clearTimeout(timer);
67 + }
68 +}
added packages/connectors/src/sitemap.ts +106 −0
@@ -0,0 +1,106 @@
1 +import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { NormalizeError, type WebSensorConnector } from "./types";
4 +import { asArray, parseXml, textOf } from "./xml";
5 +
6 +export type SitemapEntry = {
7 + key: string;
8 + url: string;
9 + lastmod: string | null;
10 + title?: string;
11 + publishedAt?: string | null;
12 + [k: string]: unknown;
13 +};
14 +
15 +export interface ParsedSitemap {
16 + kind: "urlset" | "sitemapindex";
17 + entries: SitemapEntry[];
18 + children: string[];
19 +}
20 +
21 +export function parseSitemap(text: string): ParsedSitemap {
22 + const doc = parseXml(text);
23 + if (doc.sitemapindex) {
24 + const children = asArray((doc.sitemapindex as Record<string, unknown>).sitemap as unknown[]).map((s) => textOf((s as Record<string, unknown>).loc));
25 + return { kind: "sitemapindex", entries: [], children: children.filter(Boolean) };
26 + }
27 + if (doc.urlset) {
28 + const urls = asArray((doc.urlset as Record<string, unknown>).url as unknown[]).map((u) => {
29 + const o = u as Record<string, unknown>;
30 + const url = textOf(o.loc);
31 + const news = o["news:news"] as Record<string, unknown> | undefined;
32 + const title = news ? textOf(news["news:title"]) : undefined;
33 + const pub = news ? textOf(news["news:publication_date"]) : undefined;
34 + return { key: url, url, lastmod: textOf(o.lastmod) || null, title: title || undefined, publishedAt: pub || null } satisfies SitemapEntry;
35 + });
36 + return { kind: "urlset", entries: urls.filter((e) => e.url), children: [] };
37 + }
38 + // Plain-text sitemaps (one URL per line)
39 + const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => /^https?:\/\//.test(l));
40 + if (lines.length) return { kind: "urlset", entries: lines.map((u) => ({ key: u, url: u, lastmod: null })), children: [] };
41 + throw new NormalizeError("not_a_sitemap", "Document is not a sitemap");
42 +}
43 +
44 +/**
45 + * Sitemap connector. Handles sitemap indexes (follows up to `maxChildren` children, newest
46 + * first when lastmod is available) and compressed sitemaps. Emits list diffs:
47 + * new_url / removed_url / modified lastmod.
48 + */
49 +export class SitemapConnector implements WebSensorConnector {
50 + mode = "list" as const;
51 + metadata(): ConnectorMetadata {
52 + return { key: "sitemap", name: "Sitemap", sensorTypes: ["SITEMAP"], description: "sitemap.xml / index / news sitemaps → URL created/removed/lastmod", version: "1.0.0" };
53 + }
54 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
55 + return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/xml, text/xml, application/gzip, */*;q=0.5", maxBytes: 30 * 1024 * 1024 });
56 + }
57 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
58 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
59 + const cfg = endpoint.config as { maxChildren?: number; maxUrls?: number; include?: string; exclude?: string };
60 + const text = obs.body.toString("utf8");
61 + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 500))) throw new NormalizeError("html_not_sitemap", "Endpoint returned HTML instead of a sitemap");
62 + let parsed = parseSitemap(text);
63 + let entries = parsed.entries;
64 + const fetchedChildren: string[] = [];
65 + if (parsed.kind === "sitemapindex") {
66 + const children = parsed.children.slice(0, cfg.maxChildren ?? 6);
67 + for (const child of children) {
68 + const o = await httpFetchWithRetry(endpoint.id, child, { accept: "application/xml, text/xml, application/gzip, */*;q=0.5", maxBytes: 30 * 1024 * 1024 }, 0);
69 + if (!o.body || o.meta.status >= 400) continue;
70 + try {
71 + const p = parseSitemap(o.body.toString("utf8"));
72 + entries.push(...p.entries);
73 + fetchedChildren.push(child);
74 + } catch {
75 + // skip unparseable child
76 + }
77 + }
78 + parsed = { ...parsed, entries };
79 + }
80 + if (cfg.include) {
81 + const re = new RegExp(cfg.include, "i");
82 + entries = entries.filter((e) => re.test(e.url));
83 + }
84 + if (cfg.exclude) {
85 + const re = new RegExp(cfg.exclude, "i");
86 + entries = entries.filter((e) => !re.test(e.url));
87 + }
88 + // Newest first when lastmod exists, bounded.
89 + entries.sort((a, b) => (b.lastmod ?? "").localeCompare(a.lastmod ?? ""));
90 + const max = cfg.maxUrls ?? 5000;
91 + const items = entries.slice(0, max);
92 + const canonical = items.map((e) => `${e.url}\t${e.lastmod ?? ""}`).join("\n");
93 + const newest = items.map((i) => i.lastmod).filter((x): x is string => Boolean(x)).sort().at(-1);
94 + return {
95 + mode: "list",
96 + items,
97 + compareFields: ["lastmod"],
98 + rawHash: sha256(text),
99 + canonicalHash: sha256(canonical),
100 + semanticHash: simhash(items.map((i) => i.url).join("\n")),
101 + publishedAt: newest ? new Date(newest) : null,
102 + extractionConfidence: 1,
103 + extra: { kind: parsed.kind, total: entries.length, children: fetchedChildren },
104 + };
105 + }
106 +}
added packages/connectors/src/statuspage.ts +69 −0
@@ -0,0 +1,69 @@
1 +import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";
2 +import { httpFetchWithRetry } from "./fetcher";
3 +import { NormalizeError, type WebSensorConnector } from "./types";
4 +
5 +/**
6 + * Statuspage connector (Atlassian Statuspage `/api/v2/summary.json` and compatible clones:
7 + * instatus, status.io exports, Google/AWS style JSON where configured).
8 + * Emits list items for incidents + scheduled maintenances and component states so we
9 + * detect: incident created / updated / resolved, maintenance scheduled, component degraded.
10 + */
11 +export class StatuspageConnector implements WebSensorConnector {
12 + mode = "list" as const;
13 + metadata(): ConnectorMetadata {
14 + return { key: "statuspage", name: "Statuspage", sensorTypes: ["STATUSPAGE"], description: "Atlassian Statuspage API v2 summary → incidents, maintenances, component status", version: "1.0.0" };
15 + }
16 + async fetch(endpoint: SensorEndpoint): Promise<Observation> {
17 + return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/json" });
18 + }
19 + async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {
20 + if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");
21 + const text = obs.body.toString("utf8");
22 + let j: Record<string, unknown>;
23 + try {
24 + j = JSON.parse(text) as Record<string, unknown>;
25 + } catch {
26 + throw new NormalizeError("bad_json", "Statuspage response is not JSON");
27 + }
28 + const page = (j.page ?? {}) as Record<string, unknown>;
29 + const status = (j.status ?? {}) as Record<string, unknown>;
30 + const incidents = ((j.incidents ?? []) as Record<string, unknown>[]).map((i) => incidentItem(i, "incident"));
31 + const maints = ((j.scheduled_maintenances ?? []) as Record<string, unknown>[]).map((i) => incidentItem(i, "maintenance"));
32 + const components = ((j.components ?? []) as Record<string, unknown>[])
33 + .filter((c) => !c.group && c.status !== "operational")
34 + .map((c) => ({ key: `component:${String(c.id)}`, kind: "component", title: `${String(c.name)} — ${String(c.status).replace(/_/g, " ")}`, status: String(c.status), url: String(page.url ?? ""), summary: `Component ${String(c.name)} is ${String(c.status).replace(/_/g, " ")}.`, publishedAt: c.updated_at ? new Date(String(c.updated_at)).toISOString() : null }));
35 + const items: { key: string; [k: string]: unknown }[] = [...incidents, ...maints, ...components];
36 + const overall = { key: "overall", kind: "overall", title: `Overall: ${String(status.description ?? status.indicator ?? "unknown")}`, status: String(status.indicator ?? ""), summary: String(status.description ?? ""), url: String(page.url ?? ""), publishedAt: null, updatedAt: null };
37 + items.push(overall);
38 + const canonical = items.map((i) => `${i.key}\t${String(i.status)}\t${String(i.title)}\t${String(i.updatedAt ?? "")}`).join("\n");
39 + const newest = items.map((i) => i.publishedAt as string | null).filter((x): x is string => Boolean(x)).sort().at(-1);
40 + return {
41 + mode: "list",
42 + items,
43 + compareFields: ["status", "title", "updatedAt"],
44 + title: String(page.name ?? ""),
45 + rawHash: sha256(text),
46 + canonicalHash: sha256(canonical),
47 + semanticHash: simhash(items.map((i) => String(i.title)).join("\n")),
48 + publishedAt: newest ? new Date(newest) : null,
49 + extractionConfidence: 1,
50 + extra: { indicator: status.indicator, activeIncidents: incidents.length, maintenances: maints.length, degradedComponents: components.length },
51 + };
52 + }
53 +}
54 +
55 +function incidentItem(i: Record<string, unknown>, kind: "incident" | "maintenance"): { key: string; kind: string; title: string; status: string; impact: string; url: string; summary: string; publishedAt: string | null; updatedAt: string | null } {
56 + const updates = (i.incident_updates ?? []) as Record<string, unknown>[];
57 + const latest = updates[0];
58 + return {
59 + key: `${kind}:${String(i.id)}`,
60 + kind,
61 + title: `${String(i.name)} — ${String(i.status).replace(/_/g, " ")}`,
62 + status: String(i.status),
63 + impact: String(i.impact ?? ""),
64 + url: String(i.shortlink ?? ""),
65 + summary: latest ? String(latest.body ?? "").slice(0, 800) : "",
66 + publishedAt: i.created_at ? new Date(String(i.created_at)).toISOString() : null,
67 + updatedAt: i.updated_at ? new Date(String(i.updated_at)).toISOString() : null,
68 + };
69 +}
added packages/connectors/src/types.ts +28 −0
@@ -0,0 +1,28 @@
1 +import type { ConnectorMetadata, NormalizedContent, Observation, SensorEndpoint } from "@websensor/core";
2 +
3 +/**
4 + * Connector SDK. A connector knows how to fetch one kind of endpoint and how to turn the
5 + * raw observation into comparable content. Classification is handled by the shared
6 + * pipeline (heuristics + optional LLM), but a connector may pre-classify.
7 + */
8 +export interface WebSensorConnector {
9 + metadata(): ConnectorMetadata;
10 + /** Fetch the endpoint. Must never throw for network conditions — return `error` instead. */
11 + fetch(endpoint: SensorEndpoint): Promise<Observation>;
12 + /** Normalize the observation. Throws `NormalizeError` on unparseable content. */
13 + normalize(endpoint: SensorEndpoint, observation: Observation): Promise<NormalizedContent>;
14 + /** Optional: the compare mode this connector produces (for scheduler heuristics). */
15 + mode?: "text" | "json" | "list";
16 +}
17 +
18 +export class NormalizeError extends Error {
19 + constructor(
20 + public readonly code: string,
21 + message: string,
22 + ) {
23 + super(message);
24 + this.name = "NormalizeError";
25 + }
26 +}
27 +
28 +export const PARSER_VERSION = "parser-v1";
added packages/connectors/src/xml.ts +65 −0
@@ -0,0 +1,65 @@
1 +import { XMLParser } from "fast-xml-parser";
2 +
3 +export const xmlParser = new XMLParser({
4 + ignoreAttributes: false,
5 + attributeNamePrefix: "@_",
6 + textNodeName: "#text",
7 + cdataPropName: "__cdata",
8 + trimValues: true,
9 + parseTagValue: false,
10 + parseAttributeValue: false,
11 + processEntities: true,
12 + htmlEntities: true,
13 + removeNSPrefix: false,
14 +});
15 +
16 +export function parseXml(text: string): Record<string, unknown> {
17 + // Strip BOM and leading junk some feeds emit before the prolog.
18 + const cleaned = text.replace(/^/, "").replace(/^[^<]+/, "");
19 + return xmlParser.parse(cleaned) as Record<string, unknown>;
20 +}
21 +
22 +export function asArray<T>(v: T | T[] | undefined | null): T[] {
23 + if (v === undefined || v === null) return [];
24 + return Array.isArray(v) ? v : [v];
25 +}
26 +
27 +/** Text of an XML node that may be string, cdata object or {#text}. */
28 +export function textOf(v: unknown): string {
29 + if (v === undefined || v === null) return "";
30 + if (typeof v === "string") return v.trim();
31 + if (typeof v === "number" || typeof v === "boolean") return String(v);
32 + if (typeof v === "object") {
33 + const o = v as Record<string, unknown>;
34 + if (typeof o.__cdata === "string") return o.__cdata.trim();
35 + if (typeof o["#text"] === "string") return (o["#text"] as string).trim();
36 + if (o.__cdata && typeof o.__cdata === "object") return textOf(o.__cdata);
37 + if (typeof o["@_href"] === "string") return o["@_href"].trim();
38 + }
39 + return "";
40 +}
41 +
42 +export function stripHtml(s: string): string {
43 + return s
44 + .replace(/<script[\s\S]*?<\/script>/gi, " ")
45 + .replace(/<style[\s\S]*?<\/style>/gi, " ")
46 + .replace(/<[^>]+>/g, " ")
47 + .replace(/&nbsp;/g, " ")
48 + .replace(/&amp;/g, "&")
49 + .replace(/&lt;/g, "<")
50 + .replace(/&gt;/g, ">")
51 + .replace(/&quot;/g, '"')
52 + .replace(/&#39;|&apos;/g, "'")
53 + .replace(/\s+/g, " ")
54 + .trim();
55 +}
56 +
57 +export function parseDate(v: unknown): Date | null {
58 + const s = textOf(v);
59 + if (!s) return null;
60 + const d = new Date(s);
61 + if (!Number.isNaN(d.getTime())) return d;
62 + // RFC 822 variants with odd zones like "PST"/"EDT" are handled by Date; try trimming
63 + const d2 = new Date(s.replace(/\s+[A-Z]{3,4}$/, ""));
64 + return Number.isNaN(d2.getTime()) ? null : d2;
65 +}
added packages/connectors/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added packages/core/package.json +26 −0
@@ -0,0 +1,26 @@
1 +{
2 + "name": "@websensor/core",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts",
10 + "./client": "./src/client.ts"
11 + },
12 + "scripts": {
13 + "typecheck": "tsc -p tsconfig.json --noEmit",
14 + "test": "vitest run --passWithNoTests"
15 + },
16 + "dependencies": {
17 + "cheerio": "^1.1.0",
18 + "diff": "^8.0.0",
19 + "zod": "^4.0.0"
20 + },
21 + "devDependencies": {
22 + "@types/node": "^24.0.0",
23 + "typescript": "^5.9.3",
24 + "vitest": "^3.2.0"
25 + }
26 +}
added packages/core/src/canonical.ts +196 −0
@@ -0,0 +1,196 @@
1 +import * as cheerio from "cheerio";
2 +import { sha256, simhash } from "./hash";
3 +
4 +/**
5 + * Canonical content extraction. Before diffing we strip rendering noise (scripts, styles,
6 + * nav/footer chrome, tracking params, timestamps generated at render time, random ids,
7 + * CSRF tokens…) so `canonical_hash` only moves when the content moves. `semantic_hash`
8 + * is a simhash of the canonical text and is robust to small reorderings.
9 + */
10 +
11 +export interface CanonicalResult {
12 + /** Canonical text (one block per line). */
13 + text: string;
14 + title: string | null;
15 + /** Headings (h1–h3) in order — used for section-level diffs. */
16 + headings: string[];
17 + /** Absolute links found in the main content. */
18 + links: { href: string; text: string }[];
19 + rawHash: string;
20 + canonicalHash: string;
21 + semanticHash: string;
22 + /** Structural signature: element counts by tag (used for DOM-level diff summaries). */
23 + structure: Record<string, number>;
24 + meta: Record<string, string>;
25 +}
26 +
27 +const NOISE_SELECTORS = [
28 + "script",
29 + "style",
30 + "noscript",
31 + "template",
32 + "svg",
33 + "iframe",
34 + "canvas",
35 + "video",
36 + "audio",
37 + "link",
38 + "meta",
39 + "[aria-hidden='true']",
40 + "[hidden]",
41 + ".cookie-banner",
42 + ".cookie-consent",
43 + "#cookie-banner",
44 + "#onetrust-consent-sdk",
45 + ".advertisement",
46 + ".ad-slot",
47 + "[class*='cookie']",
48 + "[id*='cookie']",
49 + "[class*='banner-consent']",
50 + "[class*='newsletter']",
51 + "[class*='social-share']",
52 + "[class*='skip-link']",
53 +];
54 +
55 +const CHROME_SELECTORS = ["header", "nav", "footer", "aside", "[role='navigation']", "[role='banner']", "[role='contentinfo']", ".sidebar", ".breadcrumb", ".breadcrumbs"];
56 +
57 +const TRACKING_PARAMS = /^(utm_|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|ref|ref_src|igshid|_hs|hsa_|vero_|yclid|wickedid|oly_|s_kwcid|ncid|cmpid|_ga|_gl|spm)/i;
58 +
59 +export function stripTrackingParams(href: string, base?: string): string {
60 + try {
61 + const u = new URL(href, base);
62 + const keys = [...u.searchParams.keys()];
63 + for (const k of keys) if (TRACKING_PARAMS.test(k)) u.searchParams.delete(k);
64 + u.hash = "";
65 + return u.toString();
66 + } catch {
67 + return href;
68 + }
69 +}
70 +
71 +/** Patterns that change on every render and carry no information. */
72 +const VOLATILE_PATTERNS: RegExp[] = [
73 + /\b\d{1,2}:\d{2}(:\d{2})?\s?(am|pm|utc|gmt|est|pst|cet)?\b/gi, // clock times
74 + /\b(generated|rendered|last updated|page last modified)\s*(on|at)?[:\s]+[^\n]{4,40}/gi,
75 + /\b[0-9a-f]{32,64}\b/gi, // hashes / tokens
76 + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, // uuids
77 + /\b(csrf|nonce|token|session)[=:]\s*[\w-]+/gi,
78 + /\b\d{1,3}(,\d{3})*\s+(views|visitors|online|reading now|comments?)\b/gi,
79 + /\b(\d+|a few|several)\s+(seconds?|minutes?|hours?)\s+ago\b/gi,
80 +];
81 +
82 +export function normalizeText(text: string): string {
83 + return text
84 + .replace(/\r\n?/g, "\n")
85 + .replace(/ /g, " ")
86 + .replace(/[ \t\f\v]+/g, " ")
87 + .split("\n")
88 + .map((l) => l.trim())
89 + .filter((l) => l.length > 0)
90 + .join("\n");
91 +}
92 +
93 +export function scrubVolatile(text: string): string {
94 + let out = text;
95 + for (const re of VOLATILE_PATTERNS) out = out.replace(re, " ");
96 + return normalizeText(out);
97 +}
98 +
99 +const BLOCK_TAGS = new Set(["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "td", "th", "table", "blockquote", "pre", "dd", "dt", "dl", "figcaption", "main", "br", "hr", "details", "summary"]);
100 +
101 +export function canonicalizeHtml(html: string, baseUrl?: string, opts: { keepChrome?: boolean } = {}): CanonicalResult {
102 + const rawHash = sha256(html);
103 + const $ = cheerio.load(html, { xml: false });
104 + const meta: Record<string, string> = {};
105 + $("meta").each((_, el) => {
106 + const name = $(el).attr("name") ?? $(el).attr("property");
107 + const content = $(el).attr("content");
108 + if (name && content && /^(description|og:title|og:description|og:type|article:published_time|article:modified_time|last-modified|generator)$/i.test(name)) meta[name.toLowerCase()] = content.trim();
109 + });
110 + const title = normalizeText($("title").first().text() || $("h1").first().text() || "") || null;
111 +
112 + $(NOISE_SELECTORS.join(",")).remove();
113 + if (!opts.keepChrome) $(CHROME_SELECTORS.join(",")).remove();
114 + // Comments
115 + $("*")
116 + .contents()
117 + .each((_, node) => {
118 + if (node.type === "comment") $(node).remove();
119 + });
120 +
121 + const root: cheerio.Cheerio<any> = $("main").length ? $("main").first() : $("article").length && $("article").text().trim().length > 200 ? $("article").first() : $("body").length ? $("body") : $.root();
122 +
123 + const headings: string[] = [];
124 + root.find("h1,h2,h3").each((_, el) => {
125 + const t = normalizeText($(el).text());
126 + if (t) headings.push(t);
127 + });
128 +
129 + const links: { href: string; text: string }[] = [];
130 + const seen = new Set<string>();
131 + root.find("a[href]").each((_, el) => {
132 + const hrefRaw = $(el).attr("href") ?? "";
133 + if (!hrefRaw || hrefRaw.startsWith("#") || /^(javascript|mailto|tel):/i.test(hrefRaw)) return;
134 + const href = stripTrackingParams(hrefRaw, baseUrl);
135 + if (!/^https?:/i.test(href) || seen.has(href)) return;
136 + seen.add(href);
137 + links.push({ href, text: normalizeText($(el).text()).slice(0, 200) });
138 + });
139 +
140 + const structure: Record<string, number> = {};
141 + root.find("*").each((_, el) => {
142 + if (el.type !== "tag") return;
143 + structure[el.name] = (structure[el.name] ?? 0) + 1;
144 + });
145 +
146 + // Text extraction with block boundaries
147 + const parts: string[] = [];
148 + const walk = (node: cheerio.Cheerio<any>): void => {
149 + node.contents().each((_, child) => {
150 + if (child.type === "text") {
151 + const t = (child as { data?: string }).data ?? "";
152 + if (t.trim()) parts.push(t);
153 + } else if (child.type === "tag") {
154 + const tag = (child as { name: string }).name;
155 + const isBlock = BLOCK_TAGS.has(tag);
156 + if (isBlock) parts.push("\n");
157 + walk($(child));
158 + if (isBlock) parts.push("\n");
159 + }
160 + });
161 + };
162 + walk(root);
163 + const text = scrubVolatile(normalizeText(parts.join("")));
164 +
165 + return {
166 + text,
167 + title,
168 + headings,
169 + links,
170 + rawHash,
171 + canonicalHash: sha256(text),
172 + semanticHash: simhash(text),
173 + structure,
174 + meta,
175 + };
176 +}
177 +
178 +/** Deterministic JSON serialization (sorted keys) for structured feeds. */
179 +export function canonicalJson(value: unknown): string {
180 + return JSON.stringify(sortKeys(value));
181 +}
182 +
183 +function sortKeys(v: unknown): unknown {
184 + if (Array.isArray(v)) return v.map(sortKeys);
185 + if (v && typeof v === "object") {
186 + const out: Record<string, unknown> = {};
187 + for (const k of Object.keys(v as Record<string, unknown>).sort()) out[k] = sortKeys((v as Record<string, unknown>)[k]);
188 + return out;
189 + }
190 + return v;
191 +}
192 +
193 +export function canonicalizeText(text: string): { text: string; rawHash: string; canonicalHash: string; semanticHash: string } {
194 + const t = scrubVolatile(normalizeText(text));
195 + return { text: t, rawHash: sha256(text), canonicalHash: sha256(t), semanticHash: simhash(t) };
196 +}
added packages/core/src/client.ts +8 −0
@@ -0,0 +1,8 @@
1 +/**
2 + * Browser-safe exports (no node: imports). Import from "@websensor/core/client" in React
3 + * client components.
4 + */
5 +export { EVENT_TYPES, eventTypeSpec, FEED_CHANNELS, CATEGORIES, TIERS, SENSOR_TYPES, ENTITY_TYPES, EVIDENCE_LABELS, CONNECTOR_HEALTH } from "./taxonomy";
6 +export type { Tier, SensorType, EntityType, EvidenceLabel, ConnectorHealth, Category } from "./taxonomy";
7 +export { slugify } from "./ids";
8 +export type { EventCandidate, NormalizedContent, SensorEndpoint } from "./types";
added packages/core/src/core.test.ts +131 −0
@@ -0,0 +1,131 @@
1 +import { describe, expect, it } from "vitest";
2 +import { canonicalizeHtml, canonicalizeText, stripTrackingParams } from "./canonical";
3 +import { diffJson, diffList, diffText, summarizeDiff } from "./diff";
4 +import { hammingHex, jaccard, shingles, simhash } from "./hash";
5 +import { describeChange, evaluateChange } from "./heuristics";
6 +import { computeConfidence, computeImportance, sourceImportanceFromTier } from "./scoring";
7 +import { nextIntervalSeconds } from "./schedule";
8 +import { assertUrlAllowed, isBlockedHostname, isBlockedIP } from "./ssrf";
9 +import { slugify } from "./ids";
10 +
11 +const page = (price: string, year: string, extra = "") => `<!doctype html><html><head><title>API Pricing · Acme</title><meta name="description" content="Prices"></head>
12 +<body><nav><a href="/">Home</a><a href="/blog">Blog</a></nav><main><h1>API Pricing</h1><p>Input: ${price} / million tokens</p><p>Output: $30 / million tokens</p>${extra}
13 +<script>window.__t=${Date.now()}</script></main><footer>© ${year} Acme · <span>12,345 visitors online</span></footer></body></html>`;
14 +
15 +describe("canonicalization", () => {
16 + it("ignores render noise (scripts, nav, copyright year, counters, tracking params)", () => {
17 + const a = canonicalizeHtml(page("$10", "2025"), "https://acme.com/pricing");
18 + const b = canonicalizeHtml(page("$10", "2026"), "https://acme.com/pricing");
19 + expect(a.canonicalHash).toBe(b.canonicalHash);
20 + expect(a.rawHash).not.toBe(b.rawHash);
21 + expect(a.title).toBe("API Pricing · Acme");
22 + expect(a.headings).toEqual(["API Pricing"]);
23 + expect(stripTrackingParams("https://x.com/a?utm_source=t&id=3&fbclid=9#frag")).toBe("https://x.com/a?id=3");
24 + });
25 + it("changes the canonical hash when content changes", () => {
26 + const a = canonicalizeHtml(page("$10", "2026"));
27 + const b = canonicalizeHtml(page("$8", "2026"));
28 + expect(a.canonicalHash).not.toBe(b.canonicalHash);
29 + expect(hammingHex(a.semanticHash, b.semanticHash)).toBeLessThan(12);
30 + });
31 + it("normalizes plain text", () => {
32 + const t = canonicalizeText("a b\r\n\r\n c \n");
33 + expect(t.text).toBe("a b\nc");
34 + });
35 +});
36 +
37 +describe("diff engines", () => {
38 + it("pairs modified lines", () => {
39 + const d = diffText("Input: $10 / million tokens\nOutput: $30\nUnchanged", "Input: $8 / million tokens\nOutput: $30\nUnchanged");
40 + expect(d.modified).toEqual([{ before: "Input: $10 / million tokens", after: "Input: $8 / million tokens" }]);
41 + expect(d.added).toEqual([]);
42 + expect(d.stats.unchangedRatio).toBeGreaterThanOrEqual(0.5);
43 + });
44 + it("diffs JSON structurally", () => {
45 + const d = diffJson({ price: { input: 10, output: 30 }, models: ["a"] }, { price: { input: 8, output: 30 }, models: ["a", "b"], region: "eu" });
46 + expect(d.changes).toEqual(expect.arrayContaining([{ path: "price.input", op: "replace", before: 10, after: 8 }, { path: "region", op: "add", after: "eu" }]));
47 + });
48 + it("diffs keyed lists", () => {
49 + const d = diffList([{ key: "1", title: "A", status: "open" }, { key: "2", title: "B" }], [{ key: "1", title: "A", status: "resolved" }, { key: "3", title: "C" }], ["status"]);
50 + expect(d.added.map((i) => i.key)).toEqual(["3"]);
51 + expect(d.removed.map((i) => i.key)).toEqual(["2"]);
52 + expect(d.modified[0]).toMatchObject({ key: "1", fields: ["status"] });
53 + expect(summarizeDiff(d)).toMatchObject({ kind: "list", counts: { added: 1, removed: 1, modified: 1 } });
54 + });
55 +});
56 +
57 +describe("heuristics", () => {
58 + it("scores a copyright bump as noise", () => {
59 + const d = diffText("© 2025 Acme\nAll rights reserved", "© 2026 Acme\nAll rights reserved");
60 + const h = evaluateChange(d, { sensorType: "HTML", url: "https://acme.com", sourceCategories: [] });
61 + expect(h.signal).toBeLessThan(0.1);
62 + expect(h.eventType).toBe("unknown");
63 + });
64 + it("detects a pricing change with extracted facts", () => {
65 + const d = diffText("Input: $10 / million tokens", "Input: $8 / million tokens");
66 + const h = evaluateChange(d, { sensorType: "HTML", url: "https://acme.com/pricing", sourceCategories: ["ai"] });
67 + expect(h.eventType).toBe("pricing_change");
68 + expect(h.signal).toBeGreaterThan(0.5);
69 + expect(h.facts[0]).toMatchObject({ kind: "price", before: "$10 / million tokens", after: "$8 / million tokens" });
70 + const desc = describeChange(h, d, { sourceName: "Acme", url: "https://acme.com/pricing", sensorName: "pricing" });
71 + expect(desc.title).toContain("$10 / million tokens → $8 / million tokens");
72 + });
73 + it("classifies new status incidents and CVEs", () => {
74 + const inc = diffList([], [{ key: "incident:1", title: "Elevated error rates — investigating", summary: "We are investigating elevated error rates on the API." }]);
75 + expect(evaluateChange(inc, { sensorType: "STATUSPAGE", url: "https://status.acme.com/api/v2/summary.json", sourceCategories: ["ai"] }).eventType).toBe("incident");
76 + const cve = diffList([], [{ key: "CVE-2026-1234", title: "CVE-2026-1234 — Acme Router: Remote Code Execution Vulnerability", summary: "Acme Router contains an RCE vulnerability." }]);
77 + expect(evaluateChange(cve, { sensorType: "REST_API", url: "https://kev.example", sourceCategories: ["cyber"] }).eventType).toBe("vulnerability");
78 + });
79 +});
80 +
81 +describe("scoring", () => {
82 + it("weights components and stays in range", () => {
83 + const r = computeImportance({ eventType: "pricing_change", sourceImportance: 92, entityImportance: 90, novelty: 90, magnitude: 60, confirmations: 1 });
84 + expect(r.score).toBeGreaterThan(75);
85 + expect(r.score).toBeLessThanOrEqual(100);
86 + expect(Object.keys(r.components)).toHaveLength(8);
87 + const low = computeImportance({ eventType: "content_change", sourceImportance: 35, entityImportance: 30, novelty: 10, magnitude: 5, confirmations: 0 });
88 + expect(low.score).toBeLessThan(35);
89 + expect(sourceImportanceFromTier("S")).toBe(92);
90 + expect(computeConfidence({ sourceAuthenticity: 1, extraction: 1, diffClarity: 1, structured: true, confirmations: 2, llmAgreement: 1 })).toBe(100);
91 + });
92 +});
93 +
94 +describe("adaptive schedule", () => {
95 + it("tightens after recent changes and backs off after errors", () => {
96 + const base = { tier: "B" as const, changes7d: 0, events7d: 0, consecutiveErrors: 0, lastWas304: false };
97 + const quiet = nextIntervalSeconds({ ...base, lastChangeAt: new Date(Date.now() - 30 * 86400e3) });
98 + const burst = nextIntervalSeconds({ ...base, lastChangeAt: new Date(Date.now() - 5 * 60e3), changes7d: 30 });
99 + const failing = nextIntervalSeconds({ ...base, lastChangeAt: null, consecutiveErrors: 4 });
100 + expect(burst).toBeLessThan(quiet);
101 + expect(burst).toBeGreaterThanOrEqual(300);
102 + expect(failing).toBeGreaterThan(quiet);
103 + });
104 +});
105 +
106 +describe("ssrf policy", () => {
107 + it("blocks private ranges, metadata and cluster hosts", async () => {
108 + expect(isBlockedIP("10.1.2.3")).toBe(true);
109 + expect(isBlockedIP("169.254.169.254")).toBe(true);
110 + expect(isBlockedIP("100.64.0.1")).toBe(true);
111 + expect(isBlockedIP("::ffff:192.168.1.1")).toBe(true);
112 + expect(isBlockedIP("8.8.8.8")).toBe(false);
113 + expect(isBlockedHostname("m3u96a.maclustr.io")).toBe(true);
114 + expect(isBlockedHostname("localhost")).toBe(true);
115 + await expect(assertUrlAllowed("http://127.0.0.1:8080/")).rejects.toThrow(/not allowed/);
116 + await expect(assertUrlAllowed("ftp://example.com")).rejects.toThrow(/Scheme/);
117 + await expect(assertUrlAllowed("https://user:pw@example.com")).rejects.toThrow(/Credentials/);
118 + });
119 +});
120 +
121 +describe("hashing", () => {
122 + it("shingles + jaccard measure similarity", () => {
123 + const a = shingles("OpenAI releases new model GPT-5 with lower pricing");
124 + const b = shingles("OpenAI releases new model GPT-5 with lower prices today");
125 + const c = shingles("FDA approves a new drug for diabetes");
126 + expect(jaccard(a, b)).toBeGreaterThan(0.3);
127 + expect(jaccard(a, c)).toBe(0);
128 + expect(simhash("x")).toHaveLength(16);
129 + expect(slugify("Anthropic: Claude Opus 5 — pricing changed!")).toBe("anthropic-claude-opus-5-pricing-changed");
130 + });
131 +});
added packages/core/src/diff.ts +176 −0
@@ -0,0 +1,176 @@
1 +import { diffLines, createTwoFilesPatch } from "diff";
2 +
3 +/**
4 + * Diff engines. All produce a `DiffResult` with a compact, storable summary plus the
5 + * unified patch so the UI can render unified / side-by-side / semantic / raw views.
6 + */
7 +
8 +export interface TextDiff {
9 + kind: "text";
10 + added: string[];
11 + removed: string[];
12 + /** Pairs of (removed → added) that look like modifications of the same line. */
13 + modified: { before: string; after: string }[];
14 + unified: string;
15 + stats: { added: number; removed: number; modified: number; unchangedRatio: number };
16 +}
17 +
18 +export interface JsonDiff {
19 + kind: "json";
20 + changes: { path: string; op: "add" | "remove" | "replace"; before?: unknown; after?: unknown }[];
21 + unified: string;
22 +}
23 +
24 +export interface ListDiff {
25 + kind: "list";
26 + added: ListItem[];
27 + removed: ListItem[];
28 + modified: { key: string; before: ListItem; after: ListItem; fields: string[] }[];
29 + unified: string;
30 +}
31 +
32 +export interface ListItem {
33 + key: string;
34 + [k: string]: unknown;
35 +}
36 +
37 +export type DiffResult = TextDiff | JsonDiff | ListDiff;
38 +
39 +function similarity(a: string, b: string): number {
40 + if (a === b) return 1;
41 + const la = a.length;
42 + const lb = b.length;
43 + if (!la || !lb) return 0;
44 + // cheap: common prefix + suffix ratio
45 + let p = 0;
46 + while (p < la && p < lb && a[p] === b[p]) p++;
47 + let s = 0;
48 + while (s < la - p && s < lb - p && a[la - 1 - s] === b[lb - 1 - s]) s++;
49 + return (p + s) / Math.max(la, lb);
50 +}
51 +
52 +export function diffText(before: string, after: string, labelBefore = "before", labelAfter = "after"): TextDiff {
53 + const parts = diffLines(before, after, { newlineIsToken: false });
54 + const added: string[] = [];
55 + const removed: string[] = [];
56 + let unchanged = 0;
57 + let total = 0;
58 + for (const part of parts) {
59 + const lines = part.value.split("\n").filter((l) => l.length);
60 + total += lines.length;
61 + if (part.added) added.push(...lines);
62 + else if (part.removed) removed.push(...lines);
63 + else unchanged += lines.length;
64 + }
65 + // Pair removed/added lines that look like edits of the same line.
66 + const modified: { before: string; after: string }[] = [];
67 + const usedAdded = new Set<number>();
68 + const remainingRemoved: string[] = [];
69 + for (const r of removed) {
70 + let best = -1;
71 + let bestScore = 0.55;
72 + for (let i = 0; i < added.length; i++) {
73 + if (usedAdded.has(i)) continue;
74 + const sc = similarity(r, added[i]!);
75 + if (sc > bestScore) {
76 + bestScore = sc;
77 + best = i;
78 + }
79 + }
80 + if (best >= 0) {
81 + usedAdded.add(best);
82 + modified.push({ before: r, after: added[best]! });
83 + } else remainingRemoved.push(r);
84 + }
85 + const remainingAdded = added.filter((_, i) => !usedAdded.has(i));
86 + const unified = createTwoFilesPatch(labelBefore, labelAfter, before, after, "", "", { context: 2 });
87 + return {
88 + kind: "text",
89 + added: remainingAdded,
90 + removed: remainingRemoved,
91 + modified,
92 + unified,
93 + stats: { added: remainingAdded.length, removed: remainingRemoved.length, modified: modified.length, unchangedRatio: total ? unchanged / total : 1 },
94 + };
95 +}
96 +
97 +export function diffJson(before: unknown, after: unknown): JsonDiff {
98 + const changes: JsonDiff["changes"] = [];
99 + const walk = (a: unknown, b: unknown, path: string): void => {
100 + if (JSON.stringify(a) === JSON.stringify(b)) return;
101 + const aObj = a && typeof a === "object" && !Array.isArray(a);
102 + const bObj = b && typeof b === "object" && !Array.isArray(b);
103 + if (aObj && bObj) {
104 + const keys = new Set([...Object.keys(a as object), ...Object.keys(b as object)]);
105 + for (const k of keys) {
106 + const p = path ? `${path}.${k}` : k;
107 + const av = (a as Record<string, unknown>)[k];
108 + const bv = (b as Record<string, unknown>)[k];
109 + if (av === undefined) changes.push({ path: p, op: "add", after: bv });
110 + else if (bv === undefined) changes.push({ path: p, op: "remove", before: av });
111 + else walk(av, bv, p);
112 + }
113 + return;
114 + }
115 + if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.length <= 200) {
116 + for (let i = 0; i < a.length; i++) walk(a[i], b[i], `${path}[${i}]`);
117 + return;
118 + }
119 + changes.push({ path: path || "$", op: "replace", before: a, after: b });
120 + };
121 + walk(before, after, "");
122 + const unified = changes.map((c) => (c.op === "add" ? `+ ${c.path}: ${JSON.stringify(c.after)}` : c.op === "remove" ? `- ${c.path}: ${JSON.stringify(c.before)}` : `- ${c.path}: ${JSON.stringify(c.before)}\n+ ${c.path}: ${JSON.stringify(c.after)}`)).join("\n");
123 + return { kind: "json", changes, unified };
124 +}
125 +
126 +/** Diff keyed lists (feed items, sitemap URLs, releases, incidents). */
127 +export function diffList(before: ListItem[], after: ListItem[], compareFields: string[] = []): ListDiff {
128 + const bm = new Map(before.map((i) => [i.key, i]));
129 + const am = new Map(after.map((i) => [i.key, i]));
130 + const added: ListItem[] = [];
131 + const removed: ListItem[] = [];
132 + const modified: ListDiff["modified"] = [];
133 + for (const [k, item] of am) {
134 + const prev = bm.get(k);
135 + if (!prev) {
136 + added.push(item);
137 + continue;
138 + }
139 + const fields = compareFields.filter((f) => JSON.stringify(prev[f]) !== JSON.stringify(item[f]));
140 + if (fields.length) modified.push({ key: k, before: prev, after: item, fields });
141 + }
142 + for (const [k, item] of bm) if (!am.has(k)) removed.push(item);
143 + const label = (i: ListItem): string => String(i.title ?? i.url ?? i.name ?? i.key);
144 + const unified = [...added.map((i) => `+ ${label(i)}`), ...removed.map((i) => `- ${label(i)}`), ...modified.map((m) => `~ ${label(m.after)} (${m.fields.join(", ")})`)].join("\n");
145 + return { kind: "list", added, removed, modified, unified };
146 +}
147 +
148 +/** Compact, storable version of a diff for the `changes` table (bounded size). */
149 +export function summarizeDiff(d: DiffResult, maxItems = 40, maxLen = 400): Record<string, unknown> {
150 + const cut = (s: string): string => (s.length > maxLen ? s.slice(0, maxLen) + "…" : s);
151 + if (d.kind === "text") {
152 + return {
153 + kind: d.kind,
154 + added: d.added.slice(0, maxItems).map(cut),
155 + removed: d.removed.slice(0, maxItems).map(cut),
156 + modified: d.modified.slice(0, maxItems).map((m) => ({ before: cut(m.before), after: cut(m.after) })),
157 + stats: d.stats,
158 + truncated: d.added.length > maxItems || d.removed.length > maxItems || d.modified.length > maxItems,
159 + };
160 + }
161 + if (d.kind === "json") return { kind: d.kind, changes: d.changes.slice(0, maxItems), truncated: d.changes.length > maxItems };
162 + return {
163 + kind: d.kind,
164 + added: d.added.slice(0, maxItems),
165 + removed: d.removed.slice(0, maxItems),
166 + modified: d.modified.slice(0, maxItems),
167 + counts: { added: d.added.length, removed: d.removed.length, modified: d.modified.length },
168 + truncated: d.added.length > maxItems || d.removed.length > maxItems,
169 + };
170 +}
171 +
172 +export function diffIsEmpty(d: DiffResult): boolean {
173 + if (d.kind === "text") return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;
174 + if (d.kind === "json") return d.changes.length === 0;
175 + return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;
176 +}
added packages/core/src/hash.ts +65 −0
@@ -0,0 +1,65 @@
1 +import { createHash } from "node:crypto";
2 +
3 +export function sha256(data: string | Uint8Array): string {
4 + return createHash("sha256").update(data).digest("hex");
5 +}
6 +
7 +export function sha1(data: string | Uint8Array): string {
8 + return createHash("sha1").update(data).digest("hex");
9 +}
10 +
11 +const STOP = new Set(
12 + "a an the and or of to in on for with by from at as is are was were be been this that these those it its into over under about after before than then there their they we you your our not no yes can will may".split(" "),
13 +);
14 +
15 +export function tokens(text: string): string[] {
16 + return text
17 + .toLowerCase()
18 + .replace(/[^\p{L}\p{N}$%.\-/]+/gu, " ")
19 + .split(/\s+/)
20 + .filter((t) => t.length > 1 && !STOP.has(t));
21 +}
22 +
23 +/** Word 3-shingles used for Jaccard similarity / novelty. */
24 +export function shingles(text: string, k = 3): Set<string> {
25 + const t = tokens(text);
26 + const out = new Set<string>();
27 + if (t.length < k) {
28 + if (t.length) out.add(t.join(" "));
29 + return out;
30 + }
31 + for (let i = 0; i <= t.length - k; i++) out.add(t.slice(i, i + k).join(" "));
32 + return out;
33 +}
34 +
35 +export function jaccard(a: Set<string>, b: Set<string>): number {
36 + if (!a.size && !b.size) return 1;
37 + let inter = 0;
38 + for (const x of a) if (b.has(x)) inter++;
39 + return inter / (a.size + b.size - inter);
40 +}
41 +
42 +/** 64-bit simhash over tokens as a hex string. Cheap semantic fingerprint for near-duplicate detection. */
43 +export function simhash(text: string): string {
44 + const v = new Array<number>(64).fill(0);
45 + for (const tok of tokens(text)) {
46 + const h = createHash("md5").update(tok).digest();
47 + for (let i = 0; i < 64; i++) {
48 + const bit = (h[i >> 3]! >> (i & 7)) & 1;
49 + v[i]! += bit ? 1 : -1;
50 + }
51 + }
52 + let hi = 0n;
53 + for (let i = 0; i < 64; i++) if (v[i]! > 0) hi |= 1n << BigInt(i);
54 + return hi.toString(16).padStart(16, "0");
55 +}
56 +
57 +export function hammingHex(a: string, b: string): number {
58 + let x = BigInt("0x" + a) ^ BigInt("0x" + b);
59 + let c = 0;
60 + while (x) {
61 + c += Number(x & 1n);
62 + x >>= 1n;
63 + }
64 + return c;
65 +}
added packages/core/src/heuristics.ts +242 −0
@@ -0,0 +1,242 @@
1 +import type { DiffResult } from "./diff";
2 +import { EVENT_TYPES, eventTypeSpec } from "./taxonomy";
3 +
4 +/**
5 + * Stage-1 interpretation: cheap deterministic rules that (a) filter noise so we never
6 + * spend an LLM call on a copyright-year bump and (b) propose an event type, a magnitude
7 + * and keywords. The LLM stage (optional) refines title/summary/type for candidates that
8 + * clear the bar.
9 + */
10 +
11 +export interface HeuristicResult {
12 + /** 0–1: how likely the change is meaningful (1 = clearly meaningful). */
13 + signal: number;
14 + /** Fraction of changed lines classified as noise. */
15 + noiseRatio: number;
16 + eventType: string;
17 + /** 0–100 magnitude of the change. */
18 + magnitude: number;
19 + keywords: string[];
20 + /** Human-readable hints explaining the decision (auditable). */
21 + reasons: string[];
22 + /** Extracted money/percent/version facts (before → after). */
23 + facts: { kind: "price" | "percent" | "version" | "number" | "date"; before?: string; after?: string }[];
24 +}
25 +
26 +const NOISE_LINE = [
27 + /^©|\bcopyright\b|\ball rights reserved\b/i,
28 + /^\d{4}$/, // a bare year
29 + /\b(20\d{2})\b.*\b(20\d{2})\b/, // year ranges in footers
30 + /^(home|menu|search|login|sign in|sign up|subscribe|share|print|back to top|skip to (main )?content|close|next|previous|read more|learn more)$/i,
31 + /^\s*[\d,.]+\s*$/, // bare numbers (counters)
32 + /\b(views?|likes?|shares?|comments?|followers?)\b\s*:?\s*[\d,.]+/i,
33 + /\bcookie|consent|privacy preferences|accept all\b/i,
34 + /^(loading|please wait)\b/i,
35 + /\b(posted|published|updated)\s+\d+\s+(seconds?|minutes?|hours?|days?)\s+ago\b/i,
36 +];
37 +
38 +const TYPE_RULES: { type: string; re: RegExp; weight: number }[] = [
39 + { type: "pricing_change", re: /\b(price|pricing|per (million|1k|1m) tokens|\$\s?\d|\s?\d|£\s?\d|usd|per month|per seat|per user|\/mo\b|billing|discount|free tier|rate card)\b/i, weight: 1 },
40 + { type: "model_release", re: /\b(new model|model release|introducing (gpt|claude|gemini|llama|mistral|grok|qwen|deepseek|o\d)|frontier model|foundation model|llm|multimodal model|parameters|context window|benchmark)\b/i, weight: 0.9 },
41 + { type: "security_advisory", re: /\b(security advisory|security bulletin|advisory|patch(ed)?|exploit(ed|ation)?|zero[- ]day|mitigation|remote code execution|privilege escalation|hotfix)\b/i, weight: 1 },
42 + { type: "vulnerability", re: /\b(cve-\d{4}-\d{4,}|cvss|vulnerabilit(y|ies)|known exploited|kev catalog)\b/i, weight: 1.1 },
43 + { type: "breach", re: /\b(data breach|breach(ed)?|compromised|unauthori[sz]ed access|leak(ed)?|exfiltrat)/i, weight: 1.1 },
44 + { type: "outage", re: /\b(outage|major outage|service disruption|unavailable|downtime|degraded performance|partial outage)\b/i, weight: 1 },
45 + { type: "incident", re: /\b(incident|investigating|identified|monitoring|resolved|postmortem|post-mortem|root cause)\b/i, weight: 0.8 },
46 + { type: "maintenance", re: /\b(scheduled maintenance|maintenance window|planned maintenance)\b/i, weight: 0.9 },
47 + { type: "recall", re: /\b(recall(s|ed)?|safety notice|stop sale|do not use)\b/i, weight: 1 },
48 + { type: "drug_approval", re: /\b(fda approv|approval|approved|authoriz(ed|ation)|indication|biologics license|new drug application|nda|bla|marketing authori[sz]ation|emergency use)\b/i, weight: 0.8 },
49 + { type: "clinical_trial", re: /\b(phase (1|2|3|i|ii|iii)|clinical trial|topline results|primary endpoint|enrollment|study results)\b/i, weight: 0.9 },
50 + { type: "regulatory_filing", re: /\b(filing|filed|rulemaking|proposed rule|final rule|notice of|federal register|docket|comment period|enforcement action|consent order)\b/i, weight: 0.8 },
51 + { type: "financial_filing", re: /\b(10-k|10-q|8-k|s-1|form 4|13f|6-k|20-f|prospectus|proxy statement|def 14a)\b/i, weight: 1 },
52 + { type: "earnings", re: /\b(earnings|quarterly results|fiscal (q[1-4]|quarter|year)|revenue (grew|increased|declined|of)|eps|guidance|net income)\b/i, weight: 1 },
53 + { type: "monetary_policy", re: /\b(interest rate|policy rate|rate decision|basis points|fomc|monetary policy|overnight rate|bank rate|quantitative)\b/i, weight: 1 },
54 + { type: "economic_release", re: /\b(cpi|consumer price index|inflation|unemployment rate|nonfarm payroll|gdp|gross domestic product|retail sales|labour force survey|labor force|housing starts|trade balance|producer price)\b/i, weight: 0.9 },
55 + { type: "acquisition", re: /\b(acqui(re|red|sition)|to acquire|merger|merge with|takeover|buyout)\b/i, weight: 1 },
56 + { type: "funding", re: /\b(series [a-h]\b|raised \$|funding round|seed round|valuation of|investment of \$)/i, weight: 0.9 },
57 + { type: "partnership", re: /\b(partnership|partners? with|collaboration with|teams? up with|joint venture|strategic alliance)\b/i, weight: 0.8 },
58 + { type: "leadership_change", re: /\b(appoint(s|ed|ment)|named (as )?(ceo|cfo|cto|coo|president|chair)|steps? down|resign(s|ed|ation)|chief (executive|financial|technology|operating) officer|board of directors|joins as)\b/i, weight: 0.9 },
59 + { type: "layoffs", re: /\b(layoffs?|laid off|job cuts|workforce reduction|restructuring|reduction in force|redundanc)/i, weight: 1 },
60 + { type: "job_expansion", re: /\b(we're hiring|now hiring|open (roles|positions)|careers?|job openings?)\b/i, weight: 0.5 },
61 + { type: "product_launch", re: /\b(introducing|launch(es|ed|ing)?|now available|available today|announc(es|ed|ing)|unveil(s|ed)|debuts?|general availability|ga release|new product)\b/i, weight: 0.8 },
62 + { type: "software_release", re: /\b(v?\d+\.\d+(\.\d+)?(-[a-z0-9.]+)?\b.*\b(release|released|changelog|patch notes|what's new)|release notes|version \d|stable release|lts\b|beta release|rc\d)/i, weight: 0.8 },
63 + { type: "repository_release", re: /\b(github release|tag v?\d|pre-release|assets? \d+)\b/i, weight: 0.6 },
64 + { type: "API_change", re: /\b(api|endpoint|sdk|deprecat(ed|ion)|sunset|breaking change|rate limit|quota|webhook|graphql|rest api|openapi|parameter)\b/i, weight: 0.8 },
65 + { type: "policy_change", re: /\b(policy|policies|usage policy|acceptable use|guidelines|code of conduct|content policy|privacy policy)\b/i, weight: 0.8 },
66 + { type: "terms_change", re: /\b(terms of (service|use)|terms and conditions|service agreement|end user license|eula|licen[cs]e terms)\b/i, weight: 0.9 },
67 + { type: "availability_change", re: /\b(discontinued|end of life|end-of-life|eol\b|no longer (available|supported)|retir(ed|ing)|sunset|coming soon|waitlist|now in (beta|preview)|preview|region(s)? available|expands? to)\b/i, weight: 0.8 },
68 + { type: "new_region", re: /\b(new region|region launch|data center|datacenter|availability zone|now available in (europe|asia|canada|australia|india|japan|brazil|uk|us-)|opens? (in|new office))\b/i, weight: 0.8 },
69 + { type: "dataset_release", re: /\b(dataset|data release|open data|benchmark suite|corpus)\b/i, weight: 0.7 },
70 + { type: "standard_update", re: /\b(rfc \d+|w3c recommendation|candidate recommendation|working draft|specification|standard(s)? (update|published)|editor's draft)\b/i, weight: 0.8 },
71 + { type: "scientific_publication", re: /\b(paper|preprint|arxiv|peer[- ]reviewed|published in (nature|science|cell|lancet|nejm)|doi:|abstract)\b/i, weight: 0.7 },
72 + { type: "government_announcement", re: /\b(minister|ministry|prime minister|president|secretary|department of|government of|executive order|statement by|press release|backgrounder)\b/i, weight: 0.6 },
73 + { type: "legal_change", re: /\b(lawsuit|court|ruling|settlement|antitrust|regulation|legislation|bill|act of|statute|compliance deadline|injunction)\b/i, weight: 0.8 },
74 + { type: "documentation_change", re: /\b(docs?|documentation|guide|tutorial|reference|quickstart|readme|faq|how to)\b/i, weight: 0.5 },
75 +];
76 +
77 +const MONEY_RE = /(?:\$||£|usd|cad|eur)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:k|m|b|million|billion))?(?:\s?\/\s?(?:1k|1m|million|m)?\s?tokens?)?/gi;
78 +const PERCENT_RE = /-?\d+(?:\.\d+)?\s?%/g;
79 +const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+){0,2}(?:-[a-z0-9.]+)?\b/gi;
80 +const DATE_RE = /\b(?:\d{4}-\d{2}-\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})\b/gi;
81 +
82 +function isNoiseLine(line: string): boolean {
83 + const l = line.trim();
84 + if (l.length < 3) return true;
85 + return NOISE_LINE.some((re) => re.test(l));
86 +}
87 +
88 +function extractFacts(before: string, after: string): HeuristicResult["facts"] {
89 + const facts: HeuristicResult["facts"] = [];
90 + const pair = (kind: HeuristicResult["facts"][number]["kind"], re: RegExp): void => {
91 + const b = [...before.matchAll(re)].map((m) => m[0]);
92 + const a = [...after.matchAll(re)].map((m) => m[0]);
93 + if (!b.length && !a.length) return;
94 + const bs = new Set(b);
95 + const as = new Set(a);
96 + const gone = b.filter((x) => !as.has(x));
97 + const fresh = a.filter((x) => !bs.has(x));
98 + if (!gone.length && !fresh.length) return;
99 + const n = Math.max(gone.length, fresh.length);
100 + for (let i = 0; i < Math.min(n, 6); i++) facts.push({ kind, before: gone[i], after: fresh[i] });
101 + };
102 + pair("price", MONEY_RE);
103 + pair("percent", PERCENT_RE);
104 + pair("version", VERSION_RE);
105 + pair("date", DATE_RE);
106 + return facts;
107 +}
108 +
109 +export function evaluateChange(diff: DiffResult, context: { sensorType: string; url: string; sourceCategories: string[]; title?: string | null }): HeuristicResult {
110 + const reasons: string[] = [];
111 + let changedLines: string[] = [];
112 + let beforeText = "";
113 + let afterText = "";
114 + let noiseRatio = 0;
115 + let magnitude = 0;
116 +
117 + if (diff.kind === "text") {
118 + const all = [...diff.added, ...diff.removed, ...diff.modified.map((m) => m.after), ...diff.modified.map((m) => m.before)];
119 + const noisy = all.filter(isNoiseLine);
120 + noiseRatio = all.length ? noisy.length / all.length : 1;
121 + changedLines = all.filter((l) => !isNoiseLine(l));
122 + beforeText = [...diff.removed, ...diff.modified.map((m) => m.before)].join("\n");
123 + afterText = [...diff.added, ...diff.modified.map((m) => m.after)].join("\n");
124 + const churn = 1 - diff.stats.unchangedRatio;
125 + magnitude = Math.min(100, Math.round(100 * Math.min(1, churn * 2 + changedLines.length / 60)));
126 + if (!changedLines.length) reasons.push("all changed lines matched noise rules");
127 + } else if (diff.kind === "json") {
128 + const meaningful = diff.changes.filter((c) => !/(timestamp|updated_at|generated|nonce|etag|request_id|_id$|token|cache)/i.test(c.path));
129 + noiseRatio = diff.changes.length ? 1 - meaningful.length / diff.changes.length : 1;
130 + changedLines = meaningful.map((c) => `${c.path}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
131 + beforeText = meaningful.map((c) => `${c.path}: ${JSON.stringify(c.before ?? "")}`).join("\n");
132 + afterText = meaningful.map((c) => `${c.path}: ${JSON.stringify(c.after ?? "")}`).join("\n");
133 + magnitude = Math.min(100, meaningful.length * 12);
134 + } else {
135 + const items = [...diff.added, ...diff.modified.map((m) => m.after)];
136 + changedLines = items.map((i) => [i.title, i.summary, i.url].filter(Boolean).join(" — ")).filter((s) => s.length);
137 + changedLines.push(...diff.removed.map((i) => `removed: ${String(i.title ?? i.url ?? i.key)}`));
138 + noiseRatio = 0;
139 + afterText = changedLines.join("\n");
140 + magnitude = Math.min(100, 20 + diff.added.length * 15 + diff.removed.length * 10 + diff.modified.length * 5);
141 + if (diff.added.length) reasons.push(`${diff.added.length} new item(s)`);
142 + if (diff.removed.length) reasons.push(`${diff.removed.length} removed item(s)`);
143 + }
144 +
145 + const corpus = (context.title ? context.title + "\n" : "") + changedLines.join("\n");
146 + const scores = new Map<string, number>();
147 + const keywords = new Set<string>();
148 + for (const rule of TYPE_RULES) {
149 + const matches = corpus.match(new RegExp(rule.re.source, rule.re.flags.includes("g") ? rule.re.flags : rule.re.flags + "g"));
150 + if (!matches?.length) continue;
151 + const s = rule.weight * Math.min(3, matches.length);
152 + scores.set(rule.type, (scores.get(rule.type) ?? 0) + s);
153 + for (const m of matches.slice(0, 3)) keywords.add(m.toLowerCase());
154 + }
155 +
156 + // Sensor-type priors: a new item in a status feed is an incident, in a release feed a release…
157 + const st = context.sensorType;
158 + if (diff.kind === "list" && diff.added.length) {
159 + if (st === "STATUSPAGE") scores.set("incident", (scores.get("incident") ?? 0) + 2);
160 + if (st === "GITHUB_RELEASE") scores.set("repository_release", (scores.get("repository_release") ?? 0) + 2);
161 + if (st === "SITEMAP") scores.set("page_created", (scores.get("page_created") ?? 0) + 1.5);
162 + if (st === "RSS" || st === "ATOM") scores.set("announcement", (scores.get("announcement") ?? 0) + 1);
163 + }
164 + if (diff.kind === "list" && diff.removed.length && st === "SITEMAP") scores.set("page_removed", (scores.get("page_removed") ?? 0) + 1.5);
165 + if (/pricing|price/i.test(context.url)) scores.set("pricing_change", (scores.get("pricing_change") ?? 0) + 1.5);
166 + if (/terms|tos\b/i.test(context.url)) scores.set("terms_change", (scores.get("terms_change") ?? 0) + 1.5);
167 + if (/polic/i.test(context.url)) scores.set("policy_change", (scores.get("policy_change") ?? 0) + 1.2);
168 + if (/docs?\.|\/docs?\/|documentation|reference/i.test(context.url)) scores.set("documentation_change", (scores.get("documentation_change") ?? 0) + 0.8);
169 + if (/status\./i.test(context.url)) scores.set("incident", (scores.get("incident") ?? 0) + 0.8);
170 + if (/changelog|release/i.test(context.url)) scores.set("software_release", (scores.get("software_release") ?? 0) + 0.8);
171 + if (/security|advisor/i.test(context.url)) scores.set("security_advisory", (scores.get("security_advisory") ?? 0) + 1);
172 +
173 + const facts = extractFacts(beforeText, afterText);
174 + if (facts.some((f) => f.kind === "price" && f.before && f.after)) {
175 + scores.set("pricing_change", (scores.get("pricing_change") ?? 0) + 3);
176 + reasons.push("price value changed");
177 + }
178 + if (facts.some((f) => f.kind === "version" && f.before && f.after)) {
179 + scores.set("software_release", (scores.get("software_release") ?? 0) + 1.5);
180 + reasons.push("version number changed");
181 + }
182 +
183 + let eventType = "content_change";
184 + let best = 0;
185 + for (const [t, s] of scores) {
186 + // prefer the more specific / severe type when tied
187 + const sev = eventTypeSpec(t).severity / 100;
188 + const v = s + sev * 0.5;
189 + if (v > best) {
190 + best = v;
191 + eventType = t;
192 + }
193 + }
194 + if (!changedLines.length) eventType = "unknown";
195 + if (eventType === "unknown" && diff.kind === "list" && (diff.added.length || diff.removed.length)) eventType = diff.added.length ? "announcement" : "page_removed";
196 +
197 + // Signal: meaningful lines, type confidence, magnitude
198 + const contentSignal = Math.min(1, changedLines.length / 3) * (1 - noiseRatio * 0.7);
199 + const typeSignal = Math.min(1, best / 3);
200 + let signal = Math.max(0, Math.min(1, 0.55 * contentSignal + 0.35 * typeSignal + 0.1 * (magnitude / 100)));
201 + if (!changedLines.length) signal = 0;
202 + if (changedLines.length === 1 && changedLines[0]!.length < 12 && !facts.length) signal = Math.min(signal, 0.15);
203 + if (!(eventType in EVENT_TYPES)) eventType = "unknown";
204 +
205 + return {
206 + signal,
207 + noiseRatio,
208 + eventType,
209 + magnitude,
210 + keywords: [...keywords].slice(0, 12),
211 + reasons,
212 + facts,
213 + };
214 +}
215 +
216 +/** Deterministic fallback title/summary when no LLM is used. */
217 +export function describeChange(h: HeuristicResult, diff: DiffResult, ctx: { sourceName: string; url: string; sensorName: string }): { title: string; summary: string } {
218 + const spec = eventTypeSpec(h.eventType);
219 + const price = h.facts.find((f) => f.kind === "price" && f.before && f.after);
220 + if (price) return { title: `${ctx.sourceName}: price changed ${price.before} → ${price.after}`, summary: `A price on ${ctx.url} changed from ${price.before} to ${price.after}.` };
221 + if (diff.kind === "list") {
222 + const first = diff.added[0];
223 + if (first && diff.added.length === 1) {
224 + const t = String(first.title ?? first.url ?? first.key);
225 + return { title: `${ctx.sourceName}: ${t}`.slice(0, 180), summary: String(first.summary ?? `New item published in ${ctx.sensorName}.`).slice(0, 600) };
226 + }
227 + if (diff.added.length > 1) return { title: `${ctx.sourceName}: ${diff.added.length} new items in ${ctx.sensorName}`, summary: diff.added.slice(0, 5).map((i) => `• ${String(i.title ?? i.url ?? i.key)}`).join("\n") };
228 + if (diff.removed.length) return { title: `${ctx.sourceName}: ${diff.removed.length} item(s) removed from ${ctx.sensorName}`, summary: diff.removed.slice(0, 5).map((i) => `• ${String(i.title ?? i.url ?? i.key)}`).join("\n") };
229 + }
230 + if (diff.kind === "json") {
231 + const c = diff.changes[0];
232 + if (c) return { title: `${ctx.sourceName}: ${spec.label.toLowerCase()} (${c.path})`, summary: `${diff.changes.length} field(s) changed on ${ctx.url}. First: ${c.path} ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`.slice(0, 600) };
233 + }
234 + if (diff.kind === "text") {
235 + const sample = (diff.modified[0]?.after ?? diff.added[0] ?? diff.removed[0] ?? "").slice(0, 140);
236 + return {
237 + title: `${ctx.sourceName}: ${spec.label.toLowerCase()} on ${ctx.sensorName}`.slice(0, 180),
238 + summary: `${diff.stats.added} line(s) added, ${diff.stats.removed} removed, ${diff.stats.modified} modified on ${ctx.url}.${sample ? ` Example: “${sample}”` : ""}`,
239 + };
240 + }
241 + return { title: `${ctx.sourceName}: ${spec.label.toLowerCase()}`, summary: `Change detected on ${ctx.url}.` };
242 +}
added packages/core/src/ids.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { randomBytes } from "node:crypto";
2 +
3 +const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
4 +
5 +/** Sortable-ish, URL-safe id: `<prefix>_<base36 time><random>`. */
6 +export function newId(prefix: string, randomLen = 12): string {
7 + const t = Date.now().toString(36);
8 + const bytes = randomBytes(randomLen);
9 + let r = "";
10 + for (let i = 0; i < randomLen; i++) r += ALPHABET[bytes[i]! % 36];
11 + return `${prefix}_${t}${r}`;
12 +}
13 +
14 +export const ID_PREFIX = {
15 + source: "src",
16 + sensor: "sen",
17 + run: "run",
18 + snapshot: "snap",
19 + change: "chg",
20 + event: "evt",
21 + cluster: "clu",
22 + entity: "ent",
23 + request: "req",
24 + watchlist: "wl",
25 + alert: "alr",
26 +} as const;
27 +
28 +export function slugify(input: string): string {
29 + return input
30 + .normalize("NFKD")
31 + .replace(/[̀-ͯ]/g, "")
32 + .toLowerCase()
33 + .replace(/[^a-z0-9]+/g, "-")
34 + .replace(/^-+|-+$/g, "")
35 + .slice(0, 80);
36 +}
added packages/core/src/index.ts +10 −0
@@ -0,0 +1,10 @@
1 +export * from "./ids";
2 +export * from "./taxonomy";
3 +export * from "./ssrf";
4 +export * from "./hash";
5 +export * from "./canonical";
6 +export * from "./diff";
7 +export * from "./heuristics";
8 +export * from "./scoring";
9 +export * from "./schedule";
10 +export * from "./types";
added packages/core/src/schedule.ts +53 −0
@@ -0,0 +1,53 @@
1 +import { TIER_BASE_INTERVAL, TIER_MAX_INTERVAL, TIER_MIN_INTERVAL, type Tier } from "./taxonomy";
2 +
3 +/**
4 + * Adaptive polling. The next interval depends on tier bounds, how recently the sensor
5 + * changed, how often it changes, error state and HTTP cache behaviour (304s are cheap
6 + * so we can afford to poll a bit faster).
7 + */
8 +export interface ScheduleInput {
9 + tier: Tier;
10 + baseIntervalSeconds?: number | null;
11 + lastChangeAt?: Date | null;
12 + /** changes in the last 7 days */
13 + changes7d: number;
14 + /** meaningful events in the last 7 days */
15 + events7d: number;
16 + consecutiveErrors: number;
17 + /** last run served 304 Not Modified */
18 + lastWas304: boolean;
19 + now?: Date;
20 +}
21 +
22 +export function nextIntervalSeconds(i: ScheduleInput): number {
23 + const now = i.now ?? new Date();
24 + const base = i.baseIntervalSeconds ?? TIER_BASE_INTERVAL[i.tier];
25 + const min = TIER_MIN_INTERVAL[i.tier];
26 + const max = TIER_MAX_INTERVAL[i.tier];
27 + let interval = base;
28 +
29 + if (i.lastChangeAt) {
30 + const ageMin = (now.getTime() - i.lastChangeAt.getTime()) / 60000;
31 + if (ageMin < 15) interval = base / 6; // burst window
32 + else if (ageMin < 60) interval = base / 3;
33 + else if (ageMin < 24 * 60) interval = base / 1.5;
34 + else if (ageMin > 14 * 24 * 60) interval = base * 3;
35 + else if (ageMin > 7 * 24 * 60) interval = base * 2;
36 + } else {
37 + interval = base * 1.5;
38 + }
39 +
40 + // Frequent changers get tighter polling; the weight is bounded so the tier still rules.
41 + const perDay = i.changes7d / 7;
42 + if (perDay >= 3) interval *= 0.6;
43 + else if (perDay >= 1) interval *= 0.8;
44 + if (i.events7d >= 3) interval *= 0.8;
45 +
46 + if (i.lastWas304) interval *= 0.85;
47 +
48 + if (i.consecutiveErrors > 0) interval = Math.max(interval, base) * Math.min(16, 2 ** i.consecutiveErrors);
49 +
50 + // ±10 % jitter to avoid thundering herds
51 + const jitter = 1 + (Math.random() * 0.2 - 0.1);
52 + return Math.round(Math.max(min, Math.min(max * (i.consecutiveErrors ? 4 : 1), interval * jitter)));
53 +}
added packages/core/src/scoring.ts +116 −0
@@ -0,0 +1,116 @@
1 +import { eventTypeSpec } from "./taxonomy";
2 +
3 +/**
4 + * Importance (0–100) with stored components:
5 + * 25 % intrinsic event severity · 20 % source importance · 15 % entity importance
6 + * 15 % novelty · 10 % magnitude · 5 % cross-source confirmation · 5 % user impact · 5 % unusualness
7 + */
8 +export interface ImportanceInput {
9 + eventType: string;
10 + /** 0–100, from the source registry (tier + importance weight). */
11 + sourceImportance: number;
12 + /** 0–100 */
13 + entityImportance: number;
14 + /** 0–100 */
15 + novelty: number;
16 + /** 0–100 */
17 + magnitude: number;
18 + /** number of independent sensors/sources confirming within the cluster window */
19 + confirmations: number;
20 + /** 0–100 — how many people are plausibly affected (pricing/terms/API/outage high). */
21 + userImpact?: number;
22 + /** 0–100 — activity anomaly of the source at detection time. */
23 + unusualness?: number;
24 +}
25 +
26 +export interface ImportanceComponents {
27 + severity: number;
28 + source: number;
29 + entity: number;
30 + novelty: number;
31 + magnitude: number;
32 + confirmation: number;
33 + userImpact: number;
34 + unusualness: number;
35 +}
36 +
37 +const USER_IMPACT_BY_TYPE: Record<string, number> = {
38 + pricing_change: 85,
39 + terms_change: 75,
40 + policy_change: 70,
41 + API_change: 70,
42 + outage: 90,
43 + incident: 65,
44 + security_advisory: 80,
45 + vulnerability: 85,
46 + breach: 95,
47 + recall: 85,
48 + availability_change: 60,
49 + model_release: 70,
50 + product_launch: 60,
51 + monetary_policy: 90,
52 + economic_release: 70,
53 + drug_approval: 65,
54 +};
55 +
56 +export function computeImportance(i: ImportanceInput): { score: number; components: ImportanceComponents } {
57 + const c: ImportanceComponents = {
58 + severity: clamp(eventTypeSpec(i.eventType).severity),
59 + source: clamp(i.sourceImportance),
60 + entity: clamp(i.entityImportance),
61 + novelty: clamp(i.novelty),
62 + magnitude: clamp(i.magnitude),
63 + confirmation: clamp(Math.min(100, i.confirmations * 35)),
64 + userImpact: clamp(i.userImpact ?? USER_IMPACT_BY_TYPE[i.eventType] ?? 40),
65 + unusualness: clamp(i.unusualness ?? 30),
66 + };
67 + const score = 0.25 * c.severity + 0.2 * c.source + 0.15 * c.entity + 0.15 * c.novelty + 0.1 * c.magnitude + 0.05 * c.confirmation + 0.05 * c.userImpact + 0.05 * c.unusualness;
68 + return { score: round1(score), components: c };
69 +}
70 +
71 +export interface ConfidenceInput {
72 + /** 0–1 how authentic the source is (official feed = 1, third-party = 0.5). */
73 + sourceAuthenticity: number;
74 + /** 0–1 extraction confidence (structured feed = 1, canonical HTML = 0.7, rendered = 0.6). */
75 + extraction: number;
76 + /** 0–1 how clean the diff is (low noise ratio = high). */
77 + diffClarity: number;
78 + /** structured data present (list/json diff) */
79 + structured: boolean;
80 + confirmations: number;
81 + /** 0–1 LLM agreement with heuristic type (1 if no LLM used but heuristics were strong). */
82 + llmAgreement: number;
83 +}
84 +
85 +export function computeConfidence(c: ConfidenceInput): number {
86 + const s = 0.25 * c.sourceAuthenticity + 0.2 * c.extraction + 0.2 * c.diffClarity + 0.1 * (c.structured ? 1 : 0.5) + 0.1 * Math.min(1, c.confirmations / 2) + 0.15 * c.llmAgreement;
87 + return round1(clamp(s * 100));
88 +}
89 +
90 +/** Tier → source importance base. */
91 +export function sourceImportanceFromTier(tier: string, weight = 1): number {
92 + const base: Record<string, number> = { S: 92, A: 78, B: 62, C: 48, D: 35 };
93 + return clamp((base[tier] ?? 50) * weight);
94 +}
95 +
96 +export function clamp(n: number, lo = 0, hi = 100): number {
97 + return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo));
98 +}
99 +export function round1(n: number): number {
100 + return Math.round(n * 10) / 10;
101 +}
102 +
103 +/** Trending score for an entity over a window. */
104 +export function trendingScore(input: { events: number; importanceSum: number; sources: number; prevEvents: number; silent: number }): number {
105 + const accel = input.prevEvents ? input.events / input.prevEvents : input.events ? 2 : 1;
106 + const s = 18 * Math.log2(1 + input.events) + 0.35 * (input.importanceSum / Math.max(1, input.events)) + 10 * Math.log2(1 + input.sources) + 12 * Math.min(2, accel) + 5 * Math.min(3, input.silent);
107 + return round1(clamp(s));
108 +}
109 +
110 +/** Activity anomaly for a source: current rate vs baseline rate → 0–100. */
111 +export function activityAnomaly(currentPerHour: number, baselinePerHour: number): number {
112 + if (baselinePerHour <= 0) return currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0;
113 + const ratio = currentPerHour / baselinePerHour;
114 + if (ratio <= 1) return round1(clamp(ratio * 30));
115 + return round1(clamp(30 + 25 * Math.log2(ratio)));
116 +}
added packages/core/src/ssrf.ts +189 −0
@@ -0,0 +1,189 @@
1 +import dns from "node:dns/promises";
2 +import net from "node:net";
3 +
4 +/**
5 + * SSRF protection. Every URL the engine fetches — seed, discovered or redirect hop —
6 + * must pass `assertUrlAllowed()` before any network activity. We validate the scheme,
7 + * the hostname, and every resolved address. `safeLookup` is used by the HTTP dispatcher
8 + * so the socket connects only to a validated address (defeats DNS rebinding).
9 + */
10 +
11 +const BLOCKED_HOSTNAMES = new Set([
12 + "localhost",
13 + "localhost.localdomain",
14 + "ip6-localhost",
15 + "ip6-loopback",
16 + "metadata.google.internal",
17 + "metadata",
18 + "instance-data",
19 + "kubernetes.default",
20 + "kubernetes.default.svc",
21 +]);
22 +
23 +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".in-addr.arpa", ".ip6.arpa", ".maclustr.io", ".ts.net"];
24 +
25 +function ipv4ToInt(ip: string): number {
26 + const p = ip.split(".").map((x) => Number(x));
27 + return ((p[0]! << 24) >>> 0) + (p[1]! << 16) + (p[2]! << 8) + p[3]!;
28 +}
29 +
30 +function inCidr4(ip: string, cidr: string): boolean {
31 + const [base, bitsStr] = cidr.split("/");
32 + const bits = Number(bitsStr);
33 + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
34 + return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base!) & mask) >>> 0);
35 +}
36 +
37 +const BLOCKED_V4 = [
38 + "0.0.0.0/8",
39 + "10.0.0.0/8",
40 + "100.64.0.0/10",
41 + "127.0.0.0/8",
42 + "169.254.0.0/16",
43 + "172.16.0.0/12",
44 + "192.0.0.0/24",
45 + "192.0.2.0/24",
46 + "192.168.0.0/16",
47 + "198.18.0.0/15",
48 + "198.51.100.0/24",
49 + "203.0.113.0/24",
50 + "224.0.0.0/4",
51 + "240.0.0.0/4",
52 + "255.255.255.255/32",
53 +];
54 +
55 +export function isBlockedIPv4(ip: string): boolean {
56 + return BLOCKED_V4.some((c) => inCidr4(ip, c));
57 +}
58 +
59 +export function isBlockedIPv6(ip: string): boolean {
60 + const lower = ip.toLowerCase();
61 + if (lower === "::" || lower === "::1") return true;
62 + const mapped = lower.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/);
63 + if (mapped) return isBlockedIPv4(mapped[1]!);
64 + const mappedHex = lower.match(/^(?:0*:)*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
65 + if (mappedHex) {
66 + const a = parseInt(mappedHex[1]!, 16);
67 + const b = parseInt(mappedHex[2]!, 16);
68 + return isBlockedIPv4(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`);
69 + }
70 + if (/^fe[89ab]/.test(lower)) return true; // link-local
71 + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local
72 + if (lower.startsWith("ff")) return true; // multicast
73 + if (lower.startsWith("64:ff9b:")) return true; // NAT64
74 + if (lower.startsWith("2001:db8:")) return true; // documentation
75 + return false;
76 +}
77 +
78 +export function isBlockedIP(ip: string): boolean {
79 + const v = net.isIP(ip);
80 + if (v === 4) return isBlockedIPv4(ip);
81 + if (v === 6) return isBlockedIPv6(ip);
82 + return true;
83 +}
84 +
85 +export function isBlockedHostname(hostname: string): boolean {
86 + const h = hostname.toLowerCase().replace(/\.$/, "");
87 + if (BLOCKED_HOSTNAMES.has(h)) return true;
88 + if (BLOCKED_SUFFIXES.some((s) => h.endsWith(s))) return true;
89 + if (!h.includes(".") && net.isIP(h) === 0) return true; // bare single-label hosts
90 + return false;
91 +}
92 +
93 +export class UrlPolicyError extends Error {
94 + constructor(
95 + message: string,
96 + public readonly reason: string,
97 + ) {
98 + super(message);
99 + this.name = "UrlPolicyError";
100 + }
101 +}
102 +
103 +export interface AllowedUrl {
104 + url: URL;
105 + hostname: string;
106 + addresses: string[];
107 +}
108 +
109 +const DNS_FALLBACK_SERVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"];
110 +
111 +/** Resolve all A/AAAA records, falling back to public resolvers when the system resolver fails. */
112 +export async function resolveAll(hostname: string): Promise<string[]> {
113 + try {
114 + const res = await dns.lookup(hostname, { all: true, verbatim: true });
115 + const addrs = res.map((r) => r.address);
116 + if (addrs.length) return addrs;
117 + } catch {
118 + // fall through
119 + }
120 + const r = new dns.Resolver({ timeout: 4000, tries: 2 });
121 + r.setServers(DNS_FALLBACK_SERVERS);
122 + const out: string[] = [];
123 + const [a, aaaa] = await Promise.allSettled([r.resolve4(hostname), r.resolve6(hostname)]);
124 + if (a.status === "fulfilled") out.push(...a.value);
125 + if (aaaa.status === "fulfilled") out.push(...aaaa.value);
126 + return out;
127 +}
128 +
129 +export interface UrlPolicyOptions {
130 + /** Resolve DNS and validate each address. Default true. */
131 + resolve?: boolean;
132 + /** Allow http:// (default true). */
133 + allowHttp?: boolean;
134 +}
135 +
136 +/** Validate a URL (scheme, host, resolved addresses). Throws UrlPolicyError. */
137 +export async function assertUrlAllowed(input: string | URL, opts: UrlPolicyOptions = {}): Promise<AllowedUrl> {
138 + let url: URL;
139 + try {
140 + url = typeof input === "string" ? new URL(input) : input;
141 + } catch {
142 + throw new UrlPolicyError("Malformed URL.", "malformed");
143 + }
144 + if (url.protocol !== "https:" && !(url.protocol === "http:" && (opts.allowHttp ?? true))) {
145 + throw new UrlPolicyError(`Scheme ${url.protocol} not allowed.`, "scheme");
146 + }
147 + if (url.username || url.password) throw new UrlPolicyError("Credentials in URL are not allowed.", "credentials");
148 + const hostname = url.hostname.replace(/^\[|\]$/g, "");
149 + if (!hostname) throw new UrlPolicyError("Missing host.", "host");
150 + if (isBlockedHostname(hostname)) throw new UrlPolicyError(`Host ${hostname} is not allowed.`, "blocked_host");
151 + if (net.isIP(hostname)) {
152 + if (isBlockedIP(hostname)) throw new UrlPolicyError(`Address ${hostname} is not allowed.`, "blocked_ip");
153 + return { url, hostname, addresses: [hostname] };
154 + }
155 + if (opts.resolve === false) return { url, hostname, addresses: [] };
156 + const addresses = await resolveAll(hostname);
157 + if (!addresses.length) throw new UrlPolicyError(`DNS resolution failed for ${hostname}.`, "dns");
158 + const bad = addresses.find((a) => isBlockedIP(a));
159 + if (bad) throw new UrlPolicyError(`Host ${hostname} resolves to a blocked address (${bad}).`, "blocked_ip");
160 + return { url, hostname, addresses };
161 +}
162 +
163 +/**
164 + * `lookup` implementation for net.connect / undici connect options: only returns
165 + * addresses that pass the policy, so the TCP connection can never reach a private range
166 + * even if DNS answers differently than during validation (rebinding).
167 + */
168 +export function safeLookup(
169 + hostname: string,
170 + options: unknown,
171 + callback: (err: NodeJS.ErrnoException | null, address: string | { address: string; family: number }[], family?: number) => void,
172 +): void {
173 + const all = typeof options === "object" && options !== null && (options as { all?: boolean }).all === true;
174 + if (isBlockedHostname(hostname)) {
175 + callback(Object.assign(new Error(`blocked host ${hostname}`), { code: "EBLOCKED" }), all ? [] : "", 4);
176 + return;
177 + }
178 + resolveAll(hostname)
179 + .then((addrs) => {
180 + const ok = addrs.filter((a) => !isBlockedIP(a));
181 + if (!ok.length) {
182 + callback(Object.assign(new Error(`no allowed address for ${hostname}`), { code: "EBLOCKED" }), all ? [] : "", 4);
183 + return;
184 + }
185 + if (all) callback(null, ok.map((a) => ({ address: a, family: net.isIP(a) })));
186 + else callback(null, ok[0]!, net.isIP(ok[0]!));
187 + })
188 + .catch((err) => callback(err, all ? [] : "", 4));
189 +}
added packages/core/src/taxonomy.ts +185 −0
@@ -0,0 +1,185 @@
1 +/**
2 + * WebSensor taxonomy. Event types are open (strings) so the set can grow without
3 + * migrations, but everything we emit ourselves comes from this list and carries an
4 + * intrinsic severity used by the importance model.
5 + */
6 +
7 +export const SENSOR_TYPES = [
8 + "WEBHOOK",
9 + "WEBSOCKET",
10 + "SSE",
11 + "REST_API",
12 + "GRAPHQL",
13 + "RSS",
14 + "ATOM",
15 + "JSON",
16 + "XML",
17 + "SITEMAP",
18 + "HTML",
19 + "RENDERED_HTML",
20 + "PDF_INDEX",
21 + "GITHUB_RELEASE",
22 + "GITHUB_REPO",
23 + "STATUSPAGE",
24 + "DNS",
25 + "TLS",
26 + "HTTP_HEADERS",
27 + "FILE",
28 + "CUSTOM_CONNECTOR",
29 +] as const;
30 +export type SensorType = (typeof SENSOR_TYPES)[number];
31 +
32 +export const TIERS = ["S", "A", "B", "C", "D"] as const;
33 +export type Tier = (typeof TIERS)[number];
34 +
35 +/** Base polling interval per tier (seconds). */
36 +export const TIER_BASE_INTERVAL: Record<Tier, number> = {
37 + S: 60,
38 + A: 180,
39 + B: 900,
40 + C: 3600,
41 + D: 21600,
42 +};
43 +export const TIER_MIN_INTERVAL: Record<Tier, number> = {
44 + S: 15,
45 + A: 60,
46 + B: 300,
47 + C: 1800,
48 + D: 21600,
49 +};
50 +export const TIER_MAX_INTERVAL: Record<Tier, number> = {
51 + S: 300,
52 + A: 1800,
53 + B: 7200,
54 + C: 21600,
55 + D: 86400,
56 +};
57 +
58 +export const CATEGORIES = [
59 + "ai",
60 + "cloud",
61 + "developer",
62 + "cyber",
63 + "consumer-tech",
64 + "semiconductors",
65 + "finance",
66 + "government",
67 + "statistics",
68 + "health",
69 + "pharma",
70 + "science",
71 + "space",
72 + "automotive",
73 + "commerce",
74 + "payments",
75 + "crypto",
76 + "enterprise",
77 + "internet",
78 + "standards",
79 + "technology",
80 +] as const;
81 +export type Category = (typeof CATEGORIES)[number] | string;
82 +
83 +/** Public feed channels → categories that map to them. */
84 +export const FEED_CHANNELS: Record<string, string[]> = {
85 + ai: ["ai"],
86 + cyber: ["cyber"],
87 + finance: ["finance", "payments", "crypto", "commerce"],
88 + health: ["health", "pharma"],
89 + government: ["government", "statistics"],
90 + science: ["science", "space"],
91 + products: ["consumer-tech", "automotive", "semiconductors", "enterprise"],
92 + infrastructure: ["cloud", "developer", "internet", "standards"],
93 +};
94 +
95 +export interface EventTypeSpec {
96 + /** Intrinsic severity 0–100 used as the 25 % component of importance. */
97 + severity: number;
98 + label: string;
99 + /** Does this type usually come with a public announcement? Used for silent-change detection. */
100 + usuallyAnnounced: boolean;
101 +}
102 +
103 +export const EVENT_TYPES: Record<string, EventTypeSpec> = {
104 + announcement: { severity: 55, label: "Announcement", usuallyAnnounced: true },
105 + product_launch: { severity: 80, label: "Product launch", usuallyAnnounced: true },
106 + product_update: { severity: 55, label: "Product update", usuallyAnnounced: true },
107 + model_release: { severity: 88, label: "Model release", usuallyAnnounced: true },
108 + software_release: { severity: 55, label: "Software release", usuallyAnnounced: true },
109 + pricing_change: { severity: 82, label: "Pricing change", usuallyAnnounced: false },
110 + availability_change: { severity: 62, label: "Availability change", usuallyAnnounced: false },
111 + policy_change: { severity: 68, label: "Policy change", usuallyAnnounced: false },
112 + terms_change: { severity: 66, label: "Terms change", usuallyAnnounced: false },
113 + documentation_change: { severity: 30, label: "Documentation change", usuallyAnnounced: false },
114 + API_change: { severity: 62, label: "API change", usuallyAnnounced: false },
115 + security_advisory: { severity: 86, label: "Security advisory", usuallyAnnounced: true },
116 + vulnerability: { severity: 90, label: "Vulnerability", usuallyAnnounced: true },
117 + breach: { severity: 95, label: "Breach", usuallyAnnounced: true },
118 + incident: { severity: 78, label: "Incident", usuallyAnnounced: true },
119 + outage: { severity: 90, label: "Outage", usuallyAnnounced: true },
120 + maintenance: { severity: 35, label: "Maintenance", usuallyAnnounced: true },
121 + recall: { severity: 85, label: "Recall", usuallyAnnounced: true },
122 + drug_approval: { severity: 88, label: "Drug approval", usuallyAnnounced: true },
123 + clinical_trial: { severity: 60, label: "Clinical trial", usuallyAnnounced: true },
124 + scientific_publication: { severity: 45, label: "Scientific publication", usuallyAnnounced: true },
125 + regulatory_filing: { severity: 65, label: "Regulatory filing", usuallyAnnounced: true },
126 + financial_filing: { severity: 68, label: "Financial filing", usuallyAnnounced: true },
127 + earnings: { severity: 75, label: "Earnings", usuallyAnnounced: true },
128 + leadership_change: { severity: 72, label: "Leadership change", usuallyAnnounced: true },
129 + acquisition: { severity: 85, label: "Acquisition", usuallyAnnounced: true },
130 + funding: { severity: 70, label: "Funding", usuallyAnnounced: true },
131 + partnership: { severity: 60, label: "Partnership", usuallyAnnounced: true },
132 + job_expansion: { severity: 40, label: "Job expansion", usuallyAnnounced: false },
133 + layoffs: { severity: 78, label: "Layoffs", usuallyAnnounced: true },
134 + new_region: { severity: 62, label: "New region", usuallyAnnounced: true },
135 + new_country: { severity: 65, label: "New country", usuallyAnnounced: true },
136 + infrastructure_change: { severity: 50, label: "Infrastructure change", usuallyAnnounced: false },
137 + DNS_change: { severity: 45, label: "DNS change", usuallyAnnounced: false },
138 + certificate_change: { severity: 30, label: "Certificate change", usuallyAnnounced: false },
139 + repository_release: { severity: 50, label: "Repository release", usuallyAnnounced: true },
140 + dataset_release: { severity: 55, label: "Dataset release", usuallyAnnounced: true },
141 + standard_update: { severity: 58, label: "Standard update", usuallyAnnounced: true },
142 + government_announcement: { severity: 65, label: "Government announcement", usuallyAnnounced: true },
143 + economic_release: { severity: 80, label: "Economic release", usuallyAnnounced: true },
144 + legal_change: { severity: 70, label: "Legal change", usuallyAnnounced: true },
145 + monetary_policy: { severity: 92, label: "Monetary policy", usuallyAnnounced: true },
146 + page_created: { severity: 35, label: "Page created", usuallyAnnounced: false },
147 + page_removed: { severity: 40, label: "Page removed", usuallyAnnounced: false },
148 + content_change: { severity: 25, label: "Content change", usuallyAnnounced: false },
149 + unknown: { severity: 20, label: "Unknown", usuallyAnnounced: false },
150 +};
151 +
152 +export function eventTypeSpec(type: string): EventTypeSpec {
153 + return EVENT_TYPES[type] ?? EVENT_TYPES.unknown!;
154 +}
155 +
156 +export const ENTITY_TYPES = [
157 + "organization",
158 + "company",
159 + "government",
160 + "agency",
161 + "product",
162 + "software",
163 + "AI_model",
164 + "API",
165 + "drug",
166 + "disease",
167 + "person",
168 + "country",
169 + "city",
170 + "stock",
171 + "crypto_asset",
172 + "repository",
173 + "standard",
174 + "technology",
175 + "dataset",
176 + "publication",
177 + "vulnerability",
178 +] as const;
179 +export type EntityType = (typeof ENTITY_TYPES)[number];
180 +
181 +export const EVIDENCE_LABELS = ["OBSERVED", "INFERRED", "CONFIRMED", "UNCONFIRMED"] as const;
182 +export type EvidenceLabel = (typeof EVIDENCE_LABELS)[number];
183 +
184 +export const CONNECTOR_HEALTH = ["UP", "DEGRADED", "ERROR", "DISABLED", "RATE_LIMITED"] as const;
185 +export type ConnectorHealth = (typeof CONNECTOR_HEALTH)[number];
added packages/core/src/types.ts +102 −0
@@ -0,0 +1,102 @@
1 +import type { DiffResult } from "./diff";
2 +import type { SensorType, Tier } from "./taxonomy";
3 +
4 +/** A monitored endpoint. */
5 +export interface SensorEndpoint {
6 + id: string;
7 + sourceId: string;
8 + name: string;
9 + url: string;
10 + type: SensorType;
11 + tier: Tier;
12 + /** connector family key (`http`, `rss`, `sitemap`, `statuspage`, `github`, `json`, `nvd`, `edgar`…) */
13 + connector: string;
14 + config: Record<string, unknown>;
15 + /** cached HTTP validators */
16 + etag?: string | null;
17 + lastModified?: string | null;
18 + /** Previous normalized state used for list diffs (feed items…) */
19 + state?: Record<string, unknown> | null;
20 +}
21 +
22 +export interface FetchMeta {
23 + status: number;
24 + url: string;
25 + finalUrl: string;
26 + contentType: string | null;
27 + contentLength: number;
28 + etag: string | null;
29 + lastModified: string | null;
30 + durationMs: number;
31 + redirects: number;
32 + method: "GET" | "HEAD" | "API";
33 + headers: Record<string, string>;
34 +}
35 +
36 +/** Raw observation — what came back from the wire. Immutable evidence. */
37 +export interface Observation {
38 + sensorId: string;
39 + url: string;
40 + fetchedAt: Date;
41 + meta: FetchMeta;
42 + /** Raw body (undefined for 304 / HEAD). */
43 + body?: Buffer;
44 + notModified: boolean;
45 + error?: { code: string; message: string };
46 +}
47 +
48 +/** Normalized, comparable representation of an observation. */
49 +export interface NormalizedContent {
50 + /** how the content should be compared */
51 + mode: "text" | "json" | "list";
52 + text?: string;
53 + json?: unknown;
54 + items?: { key: string; [k: string]: unknown }[];
55 + /** for list mode: fields whose change counts as a modification */
56 + compareFields?: string[];
57 + title?: string | null;
58 + headings?: string[];
59 + links?: { href: string; text: string }[];
60 + rawHash: string;
61 + canonicalHash: string;
62 + semanticHash: string;
63 + /** timestamp the source itself claims for the newest item / page (UTC) */
64 + publishedAt?: Date | null;
65 + /** connector-private state to persist on the sensor */
66 + state?: Record<string, unknown>;
67 + extractionConfidence: number;
68 + extra?: Record<string, unknown>;
69 +}
70 +
71 +export interface Change {
72 + id: string;
73 + sensorId: string;
74 + oldSnapshotId: string | null;
75 + newSnapshotId: string;
76 + detectedAt: Date;
77 + diff: DiffResult;
78 +}
79 +
80 +export interface EventCandidate {
81 + eventType: string;
82 + title: string;
83 + summary: string;
84 + whyItMatters?: string;
85 + importance: number;
86 + confidence: number;
87 + novelty: number;
88 + categories: string[];
89 + keywords: string[];
90 + silentChange: boolean;
91 + evidence: "OBSERVED" | "INFERRED" | "CONFIRMED" | "UNCONFIRMED";
92 + entityHints: string[];
93 + interpretation: Record<string, unknown>;
94 +}
95 +
96 +export interface ConnectorMetadata {
97 + key: string;
98 + name: string;
99 + sensorTypes: SensorType[];
100 + description: string;
101 + version: string;
102 +}
added packages/core/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added packages/db/migrations/0001_init.sql +361 −0
@@ -0,0 +1,361 @@
1 +-- WebSensor core schema (PostgreSQL 17). All timestamps are UTC (timestamptz).
2 +
3 +create table if not exists sources (
4 + id text primary key,
5 + name text not null,
6 + domain text not null,
7 + homepage text,
8 + description text,
9 + categories text[] not null default '{}',
10 + tier text not null default 'B',
11 + importance_weight real not null default 1.0,
12 + discover jsonb not null default '{}',
13 + fallback jsonb not null default '{}',
14 + enabled boolean not null default true,
15 + robots_checked_at timestamptz,
16 + terms_reviewed_at timestamptz,
17 + allowed_methods text[] not null default '{}',
18 + rate_limit_per_min integer,
19 + notes text,
20 + created_at timestamptz not null default now(),
21 + updated_at timestamptz not null default now()
22 +);
23 +create index if not exists sources_domain_idx on sources (domain);
24 +create index if not exists sources_categories_idx on sources using gin (categories);
25 +
26 +create table if not exists sensors (
27 + id text primary key,
28 + source_id text not null references sources(id) on delete cascade,
29 + name text not null,
30 + url text not null,
31 + type text not null,
32 + connector text not null,
33 + tier text not null default 'B',
34 + importance_weight real not null default 1.0,
35 + config jsonb not null default '{}',
36 + base_interval_seconds integer,
37 + enabled boolean not null default true,
38 + health text not null default 'UP',
39 + next_check_at timestamptz not null default now(),
40 + last_check_at timestamptz,
41 + last_change_at timestamptz,
42 + last_event_at timestamptz,
43 + last_status integer,
44 + last_error text,
45 + etag text,
46 + last_modified text,
47 + state jsonb,
48 + last_snapshot_id text,
49 + consecutive_errors integer not null default 0,
50 + total_runs integer not null default 0,
51 + total_not_modified integer not null default 0,
52 + raw_changes integer not null default 0,
53 + meaningful_changes integer not null default 0,
54 + avg_latency_ms integer,
55 + created_at timestamptz not null default now(),
56 + updated_at timestamptz not null default now()
57 +);
58 +create index if not exists sensors_next_check_idx on sensors (next_check_at) where enabled;
59 +create index if not exists sensors_source_idx on sensors (source_id);
60 +create index if not exists sensors_url_idx on sensors (url);
61 +
62 +create table if not exists sensor_runs (
63 + id text primary key,
64 + sensor_id text not null references sensors(id) on delete cascade,
65 + started_at timestamptz not null,
66 + finished_at timestamptz,
67 + http_status integer,
68 + outcome text not null,
69 + error text,
70 + duration_ms integer,
71 + bytes integer,
72 + fetch_method text,
73 + snapshot_id text
74 +);
75 +create index if not exists sensor_runs_sensor_idx on sensor_runs (sensor_id, started_at desc);
76 +create index if not exists sensor_runs_started_idx on sensor_runs (started_at desc);
77 +
78 +create table if not exists snapshots (
79 + id text primary key,
80 + sensor_id text not null references sensors(id) on delete cascade,
81 + url text not null,
82 + captured_at timestamptz not null,
83 + http_status integer,
84 + content_type text,
85 + content_length integer,
86 + content_hash text not null,
87 + canonical_hash text not null,
88 + semantic_hash text,
89 + etag text,
90 + last_modified text,
91 + storage_key text,
92 + canonical_storage_key text,
93 + parser_version text not null,
94 + fetch_duration_ms integer,
95 + fetch_method text,
96 + mode text not null,
97 + title text,
98 + published_at timestamptz,
99 + extraction_confidence real,
100 + extra jsonb
101 +);
102 +create index if not exists snapshots_sensor_idx on snapshots (sensor_id, captured_at desc);
103 +create index if not exists snapshots_hash_idx on snapshots (canonical_hash);
104 +
105 +create table if not exists changes (
106 + id text primary key,
107 + sensor_id text not null references sensors(id) on delete cascade,
108 + old_snapshot_id text references snapshots(id),
109 + new_snapshot_id text not null references snapshots(id),
110 + detected_at timestamptz not null,
111 + kind text not null,
112 + diff jsonb not null,
113 + diff_storage_key text,
114 + signal real not null,
115 + noise_ratio real not null,
116 + magnitude real not null,
117 + heuristic jsonb not null,
118 + meaningful boolean not null default false,
119 + event_id text
120 +);
121 +create index if not exists changes_sensor_idx on changes (sensor_id, detected_at desc);
122 +create index if not exists changes_detected_idx on changes (detected_at desc);
123 +
124 +create table if not exists event_clusters (
125 + id text primary key,
126 + title text not null,
127 + summary text,
128 + primary_event_id text,
129 + entity_ids text[] not null default '{}',
130 + categories text[] not null default '{}',
131 + event_count integer not null default 0,
132 + max_importance real not null default 0,
133 + first_at timestamptz not null,
134 + last_at timestamptz not null
135 +);
136 +create index if not exists event_clusters_last_idx on event_clusters (last_at desc);
137 +
138 +create table if not exists events (
139 + id text primary key,
140 + slug text not null unique,
141 + sensor_id text not null references sensors(id) on delete cascade,
142 + source_id text not null references sources(id) on delete cascade,
143 + cluster_id text references event_clusters(id),
144 + change_id text references changes(id),
145 + old_snapshot_id text references snapshots(id),
146 + new_snapshot_id text references snapshots(id),
147 + url text not null,
148 + event_type text not null,
149 + title text not null,
150 + summary text not null,
151 + why_it_matters text,
152 + importance real not null,
153 + importance_components jsonb not null default '{}',
154 + confidence real not null,
155 + novelty real not null,
156 + categories text[] not null default '{}',
157 + keywords text[] not null default '{}',
158 + silent_change boolean not null default false,
159 + evidence_label text not null default 'OBSERVED',
160 + published_at timestamptz,
161 + observed_from timestamptz,
162 + detected_at timestamptz not null,
163 + processed_at timestamptz not null,
164 + published_to_feed_at timestamptz,
165 + detection_latency_ms integer,
166 + processing_latency_ms integer,
167 + processing_version text not null,
168 + interpretation jsonb not null default '{}',
169 + search tsvector generated always as (
170 + setweight(to_tsvector('english'::regconfig, coalesce(title, '')), 'A') ||
171 + setweight(to_tsvector('english'::regconfig, coalesce(summary, '')), 'B') ||
172 + setweight(array_to_tsvector(coalesce(keywords, '{}'::text[])), 'C')
173 + ) stored
174 +);
175 +create index if not exists events_detected_idx on events (detected_at desc);
176 +create index if not exists events_importance_idx on events (importance desc, detected_at desc);
177 +create index if not exists events_source_idx on events (source_id, detected_at desc);
178 +create index if not exists events_sensor_idx on events (sensor_id, detected_at desc);
179 +create index if not exists events_type_idx on events (event_type);
180 +create index if not exists events_cluster_idx on events (cluster_id);
181 +create index if not exists events_categories_idx on events using gin (categories);
182 +create index if not exists events_silent_idx on events (detected_at desc) where silent_change;
183 +create index if not exists events_search_idx on events using gin (search);
184 +
185 +create table if not exists interpretations (
186 + id bigserial primary key,
187 + event_id text not null references events(id) on delete cascade,
188 + version integer not null,
189 + model text not null,
190 + payload jsonb not null,
191 + created_at timestamptz not null default now(),
192 + unique (event_id, version)
193 +);
194 +
195 +create table if not exists entities (
196 + id text primary key,
197 + name text not null,
198 + type text not null,
199 + description text,
200 + domain text,
201 + homepage text,
202 + importance real not null default 50,
203 + categories text[] not null default '{}',
204 + parent_id text references entities(id),
205 + metadata jsonb not null default '{}',
206 + event_count integer not null default 0,
207 + last_event_at timestamptz,
208 + created_at timestamptz not null default now(),
209 + search tsvector generated always as (
210 + setweight(to_tsvector('simple'::regconfig, coalesce(name, '')), 'A') ||
211 + setweight(to_tsvector('english'::regconfig, coalesce(description, '')), 'B')
212 + ) stored
213 +);
214 +create index if not exists entities_type_idx on entities (type);
215 +create index if not exists entities_search_idx on entities using gin (search);
216 +create index if not exists entities_domain_idx on entities (domain);
217 +
218 +create table if not exists entity_aliases (
219 + alias text primary key,
220 + entity_id text not null references entities(id) on delete cascade
221 +);
222 +create index if not exists entity_aliases_entity_idx on entity_aliases (entity_id);
223 +
224 +create table if not exists event_entities (
225 + event_id text not null references events(id) on delete cascade,
226 + entity_id text not null references entities(id) on delete cascade,
227 + role text not null default 'subject',
228 + primary key (event_id, entity_id)
229 +);
230 +create index if not exists event_entities_entity_idx on event_entities (entity_id);
231 +
232 +create table if not exists source_entities (
233 + source_id text not null references sources(id) on delete cascade,
234 + entity_id text not null references entities(id) on delete cascade,
235 + primary key (source_id, entity_id)
236 +);
237 +
238 +create table if not exists entity_relations (
239 + from_id text not null references entities(id) on delete cascade,
240 + relation text not null,
241 + to_id text not null references entities(id) on delete cascade,
242 + metadata jsonb not null default '{}',
243 + primary key (from_id, relation, to_id)
244 +);
245 +
246 +create table if not exists urls (
247 + url text primary key,
248 + domain text not null,
249 + source_id text references sources(id) on delete cascade,
250 + sensor_id text references sensors(id) on delete set null,
251 + first_seen_at timestamptz not null default now(),
252 + last_seen_at timestamptz not null default now(),
253 + status text not null default 'active',
254 + missing_count integer not null default 0,
255 + snapshot_count integer not null default 0,
256 + change_count integer not null default 0
257 +);
258 +create index if not exists urls_domain_idx on urls (domain);
259 +create index if not exists urls_sensor_idx on urls (sensor_id);
260 +
261 +create table if not exists url_history (
262 + id bigserial primary key,
263 + url text not null,
264 + at timestamptz not null default now(),
265 + kind text not null,
266 + snapshot_id text,
267 + change_id text,
268 + event_id text,
269 + note text
270 +);
271 +create index if not exists url_history_url_idx on url_history (url, at desc);
272 +
273 +create table if not exists watchlists (
274 + id text primary key,
275 + owner_token text not null,
276 + name text not null,
277 + created_at timestamptz not null default now()
278 +);
279 +create index if not exists watchlists_owner_idx on watchlists (owner_token);
280 +
281 +create table if not exists watchlist_items (
282 + watchlist_id text not null references watchlists(id) on delete cascade,
283 + kind text not null,
284 + value text not null,
285 + added_at timestamptz not null default now(),
286 + primary key (watchlist_id, kind, value)
287 +);
288 +
289 +create table if not exists alerts (
290 + id text primary key,
291 + owner_token text not null,
292 + name text not null,
293 + rule jsonb not null,
294 + channel text not null default 'web',
295 + enabled boolean not null default true,
296 + created_at timestamptz not null default now(),
297 + last_fired_at timestamptz
298 +);
299 +create index if not exists alerts_owner_idx on alerts (owner_token);
300 +
301 +create table if not exists notifications (
302 + id bigserial primary key,
303 + alert_id text not null references alerts(id) on delete cascade,
304 + event_id text not null references events(id) on delete cascade,
305 + created_at timestamptz not null default now(),
306 + read_at timestamptz
307 +);
308 +
309 +create table if not exists connector_health (
310 + connector text primary key,
311 + status text not null default 'UP',
312 + runs_24h integer not null default 0,
313 + errors_24h integer not null default 0,
314 + success_rate real,
315 + avg_latency_ms integer,
316 + changes_24h integer not null default 0,
317 + events_24h integer not null default 0,
318 + last_success_at timestamptz,
319 + last_error_at timestamptz,
320 + last_error text,
321 + http_codes jsonb not null default '{}',
322 + rate_limit_until timestamptz,
323 + updated_at timestamptz not null default now()
324 +);
325 +
326 +create table if not exists discovery_candidates (
327 + id text primary key,
328 + source_id text not null references sources(id) on delete cascade,
329 + url text not null,
330 + kind text not null,
331 + evidence text,
332 + score jsonb not null default '{}',
333 + status text not null default 'candidate',
334 + found_at timestamptz not null default now(),
335 + unique (source_id, url)
336 +);
337 +
338 +create table if not exists metrics_daily (
339 + day date primary key,
340 + checks bigint not null default 0,
341 + not_modified bigint not null default 0,
342 + bytes bigint not null default 0,
343 + raw_changes bigint not null default 0,
344 + events bigint not null default 0,
345 + silent_events bigint not null default 0,
346 + errors bigint not null default 0,
347 + llm_calls bigint not null default 0,
348 + llm_input_tokens bigint not null default 0,
349 + llm_output_tokens bigint not null default 0
350 +);
351 +
352 +create table if not exists llm_usage (
353 + id bigserial primary key,
354 + at timestamptz not null default now(),
355 + model text not null,
356 + purpose text not null,
357 + input_tokens integer not null,
358 + output_tokens integer not null,
359 + event_id text,
360 + ok boolean not null default true
361 +);
added packages/db/migrations/0002_scrapfly.sql +1 −0
@@ -0,0 +1 @@
1 +alter table metrics_daily add column if not exists scrapfly_calls bigint not null default 0;
added packages/db/migrations/0003_latency_bigint.sql +2 −0
@@ -0,0 +1,2 @@
1 +alter table events alter column detection_latency_ms type bigint;
2 +alter table events alter column processing_latency_ms type bigint;
added packages/db/package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "@websensor/db",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts",
10 + "./schema": "./src/schema.ts"
11 + },
12 + "scripts": {
13 + "typecheck": "tsc -p tsconfig.json --noEmit",
14 + "migrate": "tsx src/migrate.ts",
15 + "seed": "tsx src/seed.ts",
16 + "test": "vitest run --passWithNoTests"
17 + },
18 + "dependencies": {
19 + "@websensor/core": "workspace:*",
20 + "drizzle-orm": "^0.45.0",
21 + "pg": "^8.16.0",
22 + "yaml": "^2.8.0"
23 + },
24 + "devDependencies": {
25 + "@types/node": "^24.0.0",
26 + "@types/pg": "^8.15.0",
27 + "tsx": "^4.20.0",
28 + "typescript": "^5.9.3",
29 + "vitest": "^3.2.0"
30 + }
31 +}
added packages/db/src/index.ts +50 −0
@@ -0,0 +1,50 @@
1 +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
2 +import pg from "pg";
3 +import * as schema from "./schema";
4 +
5 +export * from "./schema";
6 +export { sql, eq, and, or, desc, asc, inArray, gte, lte, lt, gt, ilike, isNull, isNotNull, ne, count, max, min, avg, sum } from "drizzle-orm";
7 +export { migrate } from "./migrate";
8 +
9 +import { sql as _sql } from "drizzle-orm";
10 +/** Postgres text[] literal (Drizzle spreads JS arrays into parameter lists, which breaks `= any(...)`). */
11 +export function textArray(values: readonly string[]) {
12 + const lit = "{" + values.map((v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"').join(",") + "}";
13 + return _sql.raw("'" + lit.replace(/'/g, "''") + "'::text[]");
14 +}
15 +
16 +export type Db = NodePgDatabase<typeof schema>;
17 +
18 +let pool: pg.Pool | null = null;
19 +let dbInstance: Db | null = null;
20 +
21 +export function getPool(): pg.Pool {
22 + if (!pool) {
23 + pool = new pg.Pool({
24 + connectionString: process.env.DATABASE_URL ?? "postgres://localhost:5432/websensor",
25 + max: Number(process.env.DB_POOL_MAX ?? 10),
26 + idleTimeoutMillis: 30_000,
27 + connectionTimeoutMillis: 10_000,
28 + application_name: process.env.WS_APP_NAME ?? "websensor",
29 + });
30 + pool.on("error", (e) => console.error("[db] pool error", e.message));
31 + }
32 + return pool;
33 +}
34 +
35 +export function getDb(): Db {
36 + if (!dbInstance) dbInstance = drizzle(getPool(), { schema });
37 + return dbInstance;
38 +}
39 +
40 +export const db: Db = new Proxy({} as Db, {
41 + get(_t, prop) {
42 + return (getDb() as unknown as Record<string | symbol, unknown>)[prop];
43 + },
44 +});
45 +
46 +export async function closeDb(): Promise<void> {
47 + if (pool) await pool.end();
48 + pool = null;
49 + dbInstance = null;
50 +}
added packages/db/src/migrate.ts +50 −0
@@ -0,0 +1,50 @@
1 +import { readdirSync, readFileSync } from "node:fs";
2 +import { dirname, join } from "node:path";
3 +import { fileURLToPath } from "node:url";
4 +import pg from "pg";
5 +
6 +/** Plain-SQL forward migrations tracked in `schema_migrations`. Idempotent. */
7 +export async function migrate(databaseUrl = process.env.DATABASE_URL ?? "postgres://localhost:5432/websensor"): Promise<string[]> {
8 + const here = dirname(fileURLToPath(import.meta.url));
9 + const dir = join(here, "..", "migrations");
10 + const files = readdirSync(dir)
11 + .filter((f) => f.endsWith(".sql"))
12 + .sort();
13 + const client = new pg.Client({ connectionString: databaseUrl });
14 + await client.connect();
15 + const applied: string[] = [];
16 + try {
17 + await client.query("create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())");
18 + await client.query("select pg_advisory_lock(7245)");
19 + const done = new Set((await client.query<{ name: string }>("select name from schema_migrations")).rows.map((r) => r.name));
20 + for (const f of files) {
21 + if (done.has(f)) continue;
22 + const sql = readFileSync(join(dir, f), "utf8");
23 + await client.query("begin");
24 + try {
25 + await client.query(sql);
26 + await client.query("insert into schema_migrations (name) values ($1)", [f]);
27 + await client.query("commit");
28 + applied.push(f);
29 + } catch (e) {
30 + await client.query("rollback");
31 + throw e;
32 + }
33 + }
34 + await client.query("select pg_advisory_unlock(7245)");
35 + } finally {
36 + await client.end();
37 + }
38 + return applied;
39 +}
40 +
41 +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
42 + migrate()
43 + .then((a) => {
44 + console.log(a.length ? `applied: ${a.join(", ")}` : "schema up to date");
45 + })
46 + .catch((e) => {
47 + console.error(e);
48 + process.exit(1);
49 + });
50 +}
added packages/db/src/schema.ts +342 −0
@@ -0,0 +1,342 @@
1 +import { bigint, bigserial, boolean, date, integer, jsonb, pgTable, primaryKey, real, text, timestamp } from "drizzle-orm/pg-core";
2 +
3 +const ts = (name: string) => timestamp(name, { withTimezone: true, mode: "date" });
4 +
5 +export const sources = pgTable("sources", {
6 + id: text("id").primaryKey(),
7 + name: text("name").notNull(),
8 + domain: text("domain").notNull(),
9 + homepage: text("homepage"),
10 + description: text("description"),
11 + categories: text("categories").array().notNull().default([]),
12 + tier: text("tier").notNull().default("B"),
13 + importanceWeight: real("importance_weight").notNull().default(1),
14 + discover: jsonb("discover").$type<Record<string, unknown>>().notNull().default({}),
15 + fallback: jsonb("fallback").$type<Record<string, unknown>>().notNull().default({}),
16 + enabled: boolean("enabled").notNull().default(true),
17 + robotsCheckedAt: ts("robots_checked_at"),
18 + termsReviewedAt: ts("terms_reviewed_at"),
19 + allowedMethods: text("allowed_methods").array().notNull().default([]),
20 + rateLimitPerMin: integer("rate_limit_per_min"),
21 + notes: text("notes"),
22 + createdAt: ts("created_at").notNull().defaultNow(),
23 + updatedAt: ts("updated_at").notNull().defaultNow(),
24 +});
25 +
26 +export const sensors = pgTable("sensors", {
27 + id: text("id").primaryKey(),
28 + sourceId: text("source_id").notNull(),
29 + name: text("name").notNull(),
30 + url: text("url").notNull(),
31 + type: text("type").notNull(),
32 + connector: text("connector").notNull(),
33 + tier: text("tier").notNull().default("B"),
34 + importanceWeight: real("importance_weight").notNull().default(1),
35 + config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
36 + baseIntervalSeconds: integer("base_interval_seconds"),
37 + enabled: boolean("enabled").notNull().default(true),
38 + health: text("health").notNull().default("UP"),
39 + nextCheckAt: ts("next_check_at").notNull().defaultNow(),
40 + lastCheckAt: ts("last_check_at"),
41 + lastChangeAt: ts("last_change_at"),
42 + lastEventAt: ts("last_event_at"),
43 + lastStatus: integer("last_status"),
44 + lastError: text("last_error"),
45 + etag: text("etag"),
46 + lastModified: text("last_modified"),
47 + state: jsonb("state").$type<Record<string, unknown> | null>(),
48 + lastSnapshotId: text("last_snapshot_id"),
49 + consecutiveErrors: integer("consecutive_errors").notNull().default(0),
50 + totalRuns: integer("total_runs").notNull().default(0),
51 + totalNotModified: integer("total_not_modified").notNull().default(0),
52 + rawChanges: integer("raw_changes").notNull().default(0),
53 + meaningfulChanges: integer("meaningful_changes").notNull().default(0),
54 + avgLatencyMs: integer("avg_latency_ms"),
55 + createdAt: ts("created_at").notNull().defaultNow(),
56 + updatedAt: ts("updated_at").notNull().defaultNow(),
57 +});
58 +
59 +export const sensorRuns = pgTable("sensor_runs", {
60 + id: text("id").primaryKey(),
61 + sensorId: text("sensor_id").notNull(),
62 + startedAt: ts("started_at").notNull(),
63 + finishedAt: ts("finished_at"),
64 + httpStatus: integer("http_status"),
65 + outcome: text("outcome").notNull(),
66 + error: text("error"),
67 + durationMs: integer("duration_ms"),
68 + bytes: integer("bytes"),
69 + fetchMethod: text("fetch_method"),
70 + snapshotId: text("snapshot_id"),
71 +});
72 +
73 +export const snapshots = pgTable("snapshots", {
74 + id: text("id").primaryKey(),
75 + sensorId: text("sensor_id").notNull(),
76 + url: text("url").notNull(),
77 + capturedAt: ts("captured_at").notNull(),
78 + httpStatus: integer("http_status"),
79 + contentType: text("content_type"),
80 + contentLength: integer("content_length"),
81 + contentHash: text("content_hash").notNull(),
82 + canonicalHash: text("canonical_hash").notNull(),
83 + semanticHash: text("semantic_hash"),
84 + etag: text("etag"),
85 + lastModified: text("last_modified"),
86 + storageKey: text("storage_key"),
87 + canonicalStorageKey: text("canonical_storage_key"),
88 + parserVersion: text("parser_version").notNull(),
89 + fetchDurationMs: integer("fetch_duration_ms"),
90 + fetchMethod: text("fetch_method"),
91 + mode: text("mode").notNull(),
92 + title: text("title"),
93 + publishedAt: ts("published_at"),
94 + extractionConfidence: real("extraction_confidence"),
95 + extra: jsonb("extra").$type<Record<string, unknown> | null>(),
96 +});
97 +
98 +export const changes = pgTable("changes", {
99 + id: text("id").primaryKey(),
100 + sensorId: text("sensor_id").notNull(),
101 + oldSnapshotId: text("old_snapshot_id"),
102 + newSnapshotId: text("new_snapshot_id").notNull(),
103 + detectedAt: ts("detected_at").notNull(),
104 + kind: text("kind").notNull(),
105 + diff: jsonb("diff").$type<Record<string, unknown>>().notNull(),
106 + diffStorageKey: text("diff_storage_key"),
107 + signal: real("signal").notNull(),
108 + noiseRatio: real("noise_ratio").notNull(),
109 + magnitude: real("magnitude").notNull(),
110 + heuristic: jsonb("heuristic").$type<Record<string, unknown>>().notNull(),
111 + meaningful: boolean("meaningful").notNull().default(false),
112 + eventId: text("event_id"),
113 +});
114 +
115 +export const eventClusters = pgTable("event_clusters", {
116 + id: text("id").primaryKey(),
117 + title: text("title").notNull(),
118 + summary: text("summary"),
119 + primaryEventId: text("primary_event_id"),
120 + entityIds: text("entity_ids").array().notNull().default([]),
121 + categories: text("categories").array().notNull().default([]),
122 + eventCount: integer("event_count").notNull().default(0),
123 + maxImportance: real("max_importance").notNull().default(0),
124 + firstAt: ts("first_at").notNull(),
125 + lastAt: ts("last_at").notNull(),
126 +});
127 +
128 +export const events = pgTable("events", {
129 + id: text("id").primaryKey(),
130 + slug: text("slug").notNull(),
131 + sensorId: text("sensor_id").notNull(),
132 + sourceId: text("source_id").notNull(),
133 + clusterId: text("cluster_id"),
134 + changeId: text("change_id"),
135 + oldSnapshotId: text("old_snapshot_id"),
136 + newSnapshotId: text("new_snapshot_id"),
137 + url: text("url").notNull(),
138 + eventType: text("event_type").notNull(),
139 + title: text("title").notNull(),
140 + summary: text("summary").notNull(),
141 + whyItMatters: text("why_it_matters"),
142 + importance: real("importance").notNull(),
143 + importanceComponents: jsonb("importance_components").$type<Record<string, number>>().notNull().default({}),
144 + confidence: real("confidence").notNull(),
145 + novelty: real("novelty").notNull(),
146 + categories: text("categories").array().notNull().default([]),
147 + keywords: text("keywords").array().notNull().default([]),
148 + silentChange: boolean("silent_change").notNull().default(false),
149 + evidenceLabel: text("evidence_label").notNull().default("OBSERVED"),
150 + publishedAt: ts("published_at"),
151 + observedFrom: ts("observed_from"),
152 + detectedAt: ts("detected_at").notNull(),
153 + processedAt: ts("processed_at").notNull(),
154 + publishedToFeedAt: ts("published_to_feed_at"),
155 + detectionLatencyMs: integer("detection_latency_ms"),
156 + processingLatencyMs: integer("processing_latency_ms"),
157 + processingVersion: text("processing_version").notNull(),
158 + interpretation: jsonb("interpretation").$type<Record<string, unknown>>().notNull().default({}),
159 +});
160 +
161 +export const interpretations = pgTable("interpretations", {
162 + id: bigserial("id", { mode: "number" }).primaryKey(),
163 + eventId: text("event_id").notNull(),
164 + version: integer("version").notNull(),
165 + model: text("model").notNull(),
166 + payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
167 + createdAt: ts("created_at").notNull().defaultNow(),
168 +});
169 +
170 +export const entities = pgTable("entities", {
171 + id: text("id").primaryKey(),
172 + name: text("name").notNull(),
173 + type: text("type").notNull(),
174 + description: text("description"),
175 + domain: text("domain"),
176 + homepage: text("homepage"),
177 + importance: real("importance").notNull().default(50),
178 + categories: text("categories").array().notNull().default([]),
179 + parentId: text("parent_id"),
180 + metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
181 + eventCount: integer("event_count").notNull().default(0),
182 + lastEventAt: ts("last_event_at"),
183 + createdAt: ts("created_at").notNull().defaultNow(),
184 +});
185 +
186 +export const entityAliases = pgTable("entity_aliases", {
187 + alias: text("alias").primaryKey(),
188 + entityId: text("entity_id").notNull(),
189 +});
190 +
191 +export const eventEntities = pgTable(
192 + "event_entities",
193 + {
194 + eventId: text("event_id").notNull(),
195 + entityId: text("entity_id").notNull(),
196 + role: text("role").notNull().default("subject"),
197 + },
198 + (t) => [primaryKey({ columns: [t.eventId, t.entityId] })],
199 +);
200 +
201 +export const sourceEntities = pgTable(
202 + "source_entities",
203 + {
204 + sourceId: text("source_id").notNull(),
205 + entityId: text("entity_id").notNull(),
206 + },
207 + (t) => [primaryKey({ columns: [t.sourceId, t.entityId] })],
208 +);
209 +
210 +export const entityRelations = pgTable(
211 + "entity_relations",
212 + {
213 + fromId: text("from_id").notNull(),
214 + relation: text("relation").notNull(),
215 + toId: text("to_id").notNull(),
216 + metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
217 + },
218 + (t) => [primaryKey({ columns: [t.fromId, t.relation, t.toId] })],
219 +);
220 +
221 +export const urls = pgTable("urls", {
222 + url: text("url").primaryKey(),
223 + domain: text("domain").notNull(),
224 + sourceId: text("source_id"),
225 + sensorId: text("sensor_id"),
226 + firstSeenAt: ts("first_seen_at").notNull().defaultNow(),
227 + lastSeenAt: ts("last_seen_at").notNull().defaultNow(),
228 + status: text("status").notNull().default("active"),
229 + missingCount: integer("missing_count").notNull().default(0),
230 + snapshotCount: integer("snapshot_count").notNull().default(0),
231 + changeCount: integer("change_count").notNull().default(0),
232 +});
233 +
234 +export const urlHistory = pgTable("url_history", {
235 + id: bigserial("id", { mode: "number" }).primaryKey(),
236 + url: text("url").notNull(),
237 + at: ts("at").notNull().defaultNow(),
238 + kind: text("kind").notNull(),
239 + snapshotId: text("snapshot_id"),
240 + changeId: text("change_id"),
241 + eventId: text("event_id"),
242 + note: text("note"),
243 +});
244 +
245 +export const watchlists = pgTable("watchlists", {
246 + id: text("id").primaryKey(),
247 + ownerToken: text("owner_token").notNull(),
248 + name: text("name").notNull(),
249 + createdAt: ts("created_at").notNull().defaultNow(),
250 +});
251 +
252 +export const watchlistItems = pgTable(
253 + "watchlist_items",
254 + {
255 + watchlistId: text("watchlist_id").notNull(),
256 + kind: text("kind").notNull(),
257 + value: text("value").notNull(),
258 + addedAt: ts("added_at").notNull().defaultNow(),
259 + },
260 + (t) => [primaryKey({ columns: [t.watchlistId, t.kind, t.value] })],
261 +);
262 +
263 +export const alerts = pgTable("alerts", {
264 + id: text("id").primaryKey(),
265 + ownerToken: text("owner_token").notNull(),
266 + name: text("name").notNull(),
267 + rule: jsonb("rule").$type<Record<string, unknown>>().notNull(),
268 + channel: text("channel").notNull().default("web"),
269 + enabled: boolean("enabled").notNull().default(true),
270 + createdAt: ts("created_at").notNull().defaultNow(),
271 + lastFiredAt: ts("last_fired_at"),
272 +});
273 +
274 +export const notifications = pgTable("notifications", {
275 + id: bigserial("id", { mode: "number" }).primaryKey(),
276 + alertId: text("alert_id").notNull(),
277 + eventId: text("event_id").notNull(),
278 + createdAt: ts("created_at").notNull().defaultNow(),
279 + readAt: ts("read_at"),
280 +});
281 +
282 +export const connectorHealth = pgTable("connector_health", {
283 + connector: text("connector").primaryKey(),
284 + status: text("status").notNull().default("UP"),
285 + runs24h: integer("runs_24h").notNull().default(0),
286 + errors24h: integer("errors_24h").notNull().default(0),
287 + successRate: real("success_rate"),
288 + avgLatencyMs: integer("avg_latency_ms"),
289 + changes24h: integer("changes_24h").notNull().default(0),
290 + events24h: integer("events_24h").notNull().default(0),
291 + lastSuccessAt: ts("last_success_at"),
292 + lastErrorAt: ts("last_error_at"),
293 + lastError: text("last_error"),
294 + httpCodes: jsonb("http_codes").$type<Record<string, number>>().notNull().default({}),
295 + rateLimitUntil: ts("rate_limit_until"),
296 + updatedAt: ts("updated_at").notNull().defaultNow(),
297 +});
298 +
299 +export const discoveryCandidates = pgTable("discovery_candidates", {
300 + id: text("id").primaryKey(),
301 + sourceId: text("source_id").notNull(),
302 + url: text("url").notNull(),
303 + kind: text("kind").notNull(),
304 + evidence: text("evidence"),
305 + score: jsonb("score").$type<Record<string, unknown>>().notNull().default({}),
306 + status: text("status").notNull().default("candidate"),
307 + foundAt: ts("found_at").notNull().defaultNow(),
308 +});
309 +
310 +export const metricsDaily = pgTable("metrics_daily", {
311 + day: date("day", { mode: "string" }).primaryKey(),
312 + checks: bigint("checks", { mode: "number" }).notNull().default(0),
313 + notModified: bigint("not_modified", { mode: "number" }).notNull().default(0),
314 + bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
315 + rawChanges: bigint("raw_changes", { mode: "number" }).notNull().default(0),
316 + events: bigint("events", { mode: "number" }).notNull().default(0),
317 + silentEvents: bigint("silent_events", { mode: "number" }).notNull().default(0),
318 + errors: bigint("errors", { mode: "number" }).notNull().default(0),
319 + llmCalls: bigint("llm_calls", { mode: "number" }).notNull().default(0),
320 + llmInputTokens: bigint("llm_input_tokens", { mode: "number" }).notNull().default(0),
321 + llmOutputTokens: bigint("llm_output_tokens", { mode: "number" }).notNull().default(0),
322 + scrapflyCalls: bigint("scrapfly_calls", { mode: "number" }).notNull().default(0),
323 +});
324 +
325 +export const llmUsage = pgTable("llm_usage", {
326 + id: bigserial("id", { mode: "number" }).primaryKey(),
327 + at: ts("at").notNull().defaultNow(),
328 + model: text("model").notNull(),
329 + purpose: text("purpose").notNull(),
330 + inputTokens: integer("input_tokens").notNull(),
331 + outputTokens: integer("output_tokens").notNull(),
332 + eventId: text("event_id"),
333 + ok: boolean("ok").notNull().default(true),
334 +});
335 +
336 +export type Source = typeof sources.$inferSelect;
337 +export type Sensor = typeof sensors.$inferSelect;
338 +export type Snapshot = typeof snapshots.$inferSelect;
339 +export type Change = typeof changes.$inferSelect;
340 +export type Event = typeof events.$inferSelect;
341 +export type EventCluster = typeof eventClusters.$inferSelect;
342 +export type Entity = typeof entities.$inferSelect;
added packages/db/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added packages/store/package.json +21 −0
@@ -0,0 +1,21 @@
1 +{
2 + "name": "@websensor/store",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": { ".": "./src/index.ts" },
9 + "scripts": {
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run --passWithNoTests"
12 + },
13 + "dependencies": {
14 + "@websensor/core": "workspace:*"
15 + },
16 + "devDependencies": {
17 + "@types/node": "^24.0.0",
18 + "typescript": "^5.9.3",
19 + "vitest": "^3.2.0"
20 + }
21 +}
added packages/store/src/index.ts +69 −0
@@ -0,0 +1,69 @@
1 +import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2 +import { dirname, join, resolve } from "node:path";
3 +import { zstdCompressSync, zstdDecompressSync } from "node:zlib";
4 +import { sha256 } from "@websensor/core";
5 +
6 +/**
7 + * Content-addressed blob store for snapshots and diffs. Identical content is stored once
8 + * (`sha256/ab/cd/<hash>.zst`). The interface is storage-agnostic so an S3/MinIO driver can
9 + * be added without touching callers.
10 + */
11 +export interface BlobStore {
12 + put(data: Buffer | string, meta?: { contentType?: string }): Promise<{ key: string; bytes: number; deduplicated: boolean }>;
13 + get(key: string): Promise<Buffer>;
14 + getText(key: string): Promise<string>;
15 + exists(key: string): Promise<boolean>;
16 +}
17 +
18 +export class FsBlobStore implements BlobStore {
19 + constructor(private readonly root: string) {}
20 +
21 + private pathFor(key: string): string {
22 + return join(this.root, key + ".zst");
23 + }
24 +
25 + static keyFor(hash: string): string {
26 + return `sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
27 + }
28 +
29 + async put(data: Buffer | string): Promise<{ key: string; bytes: number; deduplicated: boolean }> {
30 + const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
31 + const key = FsBlobStore.keyFor(sha256(buf));
32 + const p = this.pathFor(key);
33 + try {
34 + const s = await stat(p);
35 + if (s.size > 0) return { key, bytes: buf.length, deduplicated: true };
36 + } catch {
37 + // not present
38 + }
39 + await mkdir(dirname(p), { recursive: true });
40 + const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
41 + await writeFile(tmp, zstdCompressSync(buf));
42 + await rename(tmp, p);
43 + return { key, bytes: buf.length, deduplicated: false };
44 + }
45 +
46 + async get(key: string): Promise<Buffer> {
47 + const raw = await readFile(this.pathFor(key));
48 + return zstdDecompressSync(raw);
49 + }
50 +
51 + async getText(key: string): Promise<string> {
52 + return (await this.get(key)).toString("utf8");
53 + }
54 +
55 + async exists(key: string): Promise<boolean> {
56 + try {
57 + await stat(this.pathFor(key));
58 + return true;
59 + } catch {
60 + return false;
61 + }
62 + }
63 +}
64 +
65 +let store: BlobStore | null = null;
66 +export function getBlobStore(): BlobStore {
67 + if (!store) store = new FsBlobStore(resolve(process.env.BLOB_STORE_DIR ?? "./data/blobs"));
68 + return store;
69 +}
added packages/store/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added pnpm-lock.yaml +6482 −0
@@ -0,0 +1,6482 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + devDependencies:
11 + '@types/node':
12 + specifier: ^24.0.0
13 + version: 24.13.3
14 + tsx:
15 + specifier: ^4.20.0
16 + version: 4.23.13
17 + typescript:
18 + specifier: ^5.9.3
19 + version: 5.9.3
20 + vitest:
21 + specifier: ^3.2.0
22 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
23 +
24 + apps/api:
25 + dependencies:
26 + '@fastify/cors':
27 + specifier: ^11.0.0
28 + version: 11.3.0
29 + '@fastify/rate-limit':
30 + specifier: ^10.3.0
31 + version: 10.3.0
32 + '@fastify/reply-from':
33 + specifier: ^12.6.5
34 + version: 12.6.5
35 + '@fastify/websocket':
36 + specifier: ^11.2.0
37 + version: 11.3.0
38 + '@websensor/core':
39 + specifier: workspace:*
40 + version: link:../../packages/core
41 + '@websensor/db':
42 + specifier: workspace:*
43 + version: link:../../packages/db
44 + '@websensor/store':
45 + specifier: workspace:*
46 + version: link:../../packages/store
47 + fastify:
48 + specifier: ^5.4.0
49 + version: 5.12.3
50 + ioredis:
51 + specifier: ^5.6.0
52 + version: 5.11.1
53 + pino:
54 + specifier: ^9.7.0
55 + version: 9.14.0
56 + pino-pretty:
57 + specifier: ^13.0.0
58 + version: 13.1.3
59 + prom-client:
60 + specifier: ^15.1.0
61 + version: 15.1.3
62 + tsx:
63 + specifier: ^4.20.0
64 + version: 4.23.13
65 + ws:
66 + specifier: ^8.21.3
67 + version: 8.21.3
68 + zod:
69 + specifier: ^4.0.0
70 + version: 4.5.4
71 + devDependencies:
72 + '@types/node':
73 + specifier: ^24.0.0
74 + version: 24.13.3
75 + '@types/ws':
76 + specifier: ^8.18.1
77 + version: 8.18.1
78 + typescript:
79 + specifier: ^5.9.3
80 + version: 5.9.3
81 + vitest:
82 + specifier: ^3.2.0
83 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
84 +
85 + apps/engine:
86 + dependencies:
87 + '@anthropic-ai/sdk':
88 + specifier: ^0.124.0
89 + version: 0.124.0(zod@4.5.4)
90 + '@websensor/connectors':
91 + specifier: workspace:*
92 + version: link:../../packages/connectors
93 + '@websensor/core':
94 + specifier: workspace:*
95 + version: link:../../packages/core
96 + '@websensor/db':
97 + specifier: workspace:*
98 + version: link:../../packages/db
99 + '@websensor/store':
100 + specifier: workspace:*
101 + version: link:../../packages/store
102 + fastify:
103 + specifier: ^5.4.0
104 + version: 5.12.3
105 + ioredis:
106 + specifier: ^5.6.0
107 + version: 5.11.1
108 + pino:
109 + specifier: ^9.7.0
110 + version: 9.14.0
111 + pino-pretty:
112 + specifier: ^13.0.0
113 + version: 13.1.3
114 + prom-client:
115 + specifier: ^15.1.0
116 + version: 15.1.3
117 + tsx:
118 + specifier: ^4.20.0
119 + version: 4.23.13
120 + yaml:
121 + specifier: ^2.8.0
122 + version: 2.9.0
123 + zod:
124 + specifier: ^4.0.0
125 + version: 4.5.4
126 + devDependencies:
127 + '@types/node':
128 + specifier: ^24.0.0
129 + version: 24.13.3
130 + typescript:
131 + specifier: ^5.9.3
132 + version: 5.9.3
133 + vitest:
134 + specifier: ^3.2.0
135 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
136 +
137 + apps/web:
138 + dependencies:
139 + '@websensor/core':
140 + specifier: workspace:*
141 + version: link:../../packages/core
142 + lucide-react:
143 + specifier: ^1.0.0
144 + version: 1.41.0(react@19.2.8)
145 + next:
146 + specifier: 16.3.4
147 + version: 16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
148 + next-themes:
149 + specifier: ^0.4.6
150 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
151 + react:
152 + specifier: 19.2.8
153 + version: 19.2.8
154 + react-dom:
155 + specifier: 19.2.8
156 + version: 19.2.8(react@19.2.8)
157 + devDependencies:
158 + '@tailwindcss/postcss':
159 + specifier: ^4
160 + version: 4.3.3
161 + '@types/node':
162 + specifier: ^24.0.0
163 + version: 24.13.3
164 + '@types/react':
165 + specifier: ^19
166 + version: 19.2.18
167 + '@types/react-dom':
168 + specifier: ^19
169 + version: 19.2.7(@types/react@19.2.18)
170 + eslint:
171 + specifier: ^9
172 + version: 9.39.5(jiti@2.7.0)
173 + eslint-config-next:
174 + specifier: 16.3.4
175 + version: 16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
176 + tailwindcss:
177 + specifier: ^4
178 + version: 4.3.3
179 + typescript:
180 + specifier: ^5.9.3
181 + version: 5.9.3
182 + vitest:
183 + specifier: ^3.2.0
184 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
185 +
186 + packages/connectors:
187 + dependencies:
188 + '@websensor/core':
189 + specifier: workspace:*
190 + version: link:../core
191 + fast-xml-parser:
192 + specifier: ^5.2.0
193 + version: 5.11.1
194 + undici:
195 + specifier: ^7.10.0
196 + version: 7.29.1
197 + devDependencies:
198 + '@types/node':
199 + specifier: ^24.0.0
200 + version: 24.13.3
201 + typescript:
202 + specifier: ^5.9.3
203 + version: 5.9.3
204 + vitest:
205 + specifier: ^3.2.0
206 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
207 +
208 + packages/core:
209 + dependencies:
210 + cheerio:
211 + specifier: ^1.1.0
212 + version: 1.2.0
213 + diff:
214 + specifier: ^8.0.0
215 + version: 8.0.4
216 + zod:
217 + specifier: ^4.0.0
218 + version: 4.5.4
219 + devDependencies:
220 + '@types/node':
221 + specifier: ^24.0.0
222 + version: 24.13.3
223 + typescript:
224 + specifier: ^5.9.3
225 + version: 5.9.3
226 + vitest:
227 + specifier: ^3.2.0
228 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
229 +
230 + packages/db:
231 + dependencies:
232 + '@websensor/core':
233 + specifier: workspace:*
234 + version: link:../core
235 + drizzle-orm:
236 + specifier: ^0.45.0
237 + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)
238 + pg:
239 + specifier: ^8.16.0
240 + version: 8.23.0
241 + yaml:
242 + specifier: ^2.8.0
243 + version: 2.9.0
244 + devDependencies:
245 + '@types/node':
246 + specifier: ^24.0.0
247 + version: 24.13.3
248 + '@types/pg':
249 + specifier: ^8.15.0
250 + version: 8.23.1
251 + tsx:
252 + specifier: ^4.20.0
253 + version: 4.23.13
254 + typescript:
255 + specifier: ^5.9.3
256 + version: 5.9.3
257 + vitest:
258 + specifier: ^3.2.0
259 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
260 +
261 + packages/store:
262 + dependencies:
263 + '@websensor/core':
264 + specifier: workspace:*
265 + version: link:../core
266 + devDependencies:
267 + '@types/node':
268 + specifier: ^24.0.0
269 + version: 24.13.3
270 + typescript:
271 + specifier: ^5.9.3
272 + version: 5.9.3
273 + vitest:
274 + specifier: ^3.2.0
275 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
276 +
277 +packages:
278 +
279 + '@alloc/quick-lru@5.3.0':
280 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
281 + engines: {node: '>=10'}
282 +
283 + '@anthropic-ai/sdk@0.124.0':
284 + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==}
285 + hasBin: true
286 + peerDependencies:
287 + zod: ^3.25.0 || ^4.0.0
288 + peerDependenciesMeta:
289 + zod:
290 + optional: true
291 +
292 + '@babel/code-frame@7.29.7':
293 + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
294 + engines: {node: '>=6.9.0'}
295 +
296 + '@babel/compat-data@7.29.7':
297 + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
298 + engines: {node: '>=6.9.0'}
299 +
300 + '@babel/core@7.29.7':
301 + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
302 + engines: {node: '>=6.9.0'}
303 +
304 + '@babel/generator@7.29.8':
305 + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
306 + engines: {node: '>=6.9.0'}
307 +
308 + '@babel/helper-compilation-targets@7.29.7':
309 + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
310 + engines: {node: '>=6.9.0'}
311 +
312 + '@babel/helper-globals@7.29.7':
313 + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
314 + engines: {node: '>=6.9.0'}
315 +
316 + '@babel/helper-module-imports@7.29.7':
317 + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
318 + engines: {node: '>=6.9.0'}
319 +
320 + '@babel/helper-module-transforms@7.29.7':
321 + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
322 + engines: {node: '>=6.9.0'}
323 + peerDependencies:
324 + '@babel/core': ^7.0.0
325 +
326 + '@babel/helper-string-parser@7.29.7':
327 + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
328 + engines: {node: '>=6.9.0'}
329 +
330 + '@babel/helper-validator-identifier@7.29.7':
331 + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
332 + engines: {node: '>=6.9.0'}
333 +
334 + '@babel/helper-validator-option@7.29.7':
335 + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
336 + engines: {node: '>=6.9.0'}
337 +
338 + '@babel/helpers@7.29.7':
339 + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
340 + engines: {node: '>=6.9.0'}
341 +
342 + '@babel/parser@7.29.8':
343 + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
344 + engines: {node: '>=6.0.0'}
345 + hasBin: true
346 +
347 + '@babel/runtime@7.29.7':
348 + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
349 + engines: {node: '>=6.9.0'}
350 +
351 + '@babel/template@7.29.7':
352 + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
353 + engines: {node: '>=6.9.0'}
354 +
355 + '@babel/traverse@7.29.8':
356 + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
357 + engines: {node: '>=6.9.0'}
358 +
359 + '@babel/types@7.29.8':
360 + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
361 + engines: {node: '>=6.9.0'}
362 +
363 + '@emnapi/core@1.10.0':
364 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
365 +
366 + '@emnapi/runtime@1.10.0':
367 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
368 +
369 + '@emnapi/runtime@1.11.3':
370 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
371 +
372 + '@emnapi/wasi-threads@1.2.1':
373 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
374 +
375 + '@esbuild/aix-ppc64@0.28.2':
376 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
377 + engines: {node: '>=18'}
378 + cpu: [ppc64]
379 + os: [aix]
380 +
381 + '@esbuild/android-arm64@0.28.2':
382 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
383 + engines: {node: '>=18'}
384 + cpu: [arm64]
385 + os: [android]
386 +
387 + '@esbuild/android-arm@0.28.2':
388 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
389 + engines: {node: '>=18'}
390 + cpu: [arm]
391 + os: [android]
392 +
393 + '@esbuild/android-x64@0.28.2':
394 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
395 + engines: {node: '>=18'}
396 + cpu: [x64]
397 + os: [android]
398 +
399 + '@esbuild/darwin-arm64@0.28.2':
400 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
401 + engines: {node: '>=18'}
402 + cpu: [arm64]
403 + os: [darwin]
404 +
405 + '@esbuild/darwin-x64@0.28.2':
406 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
407 + engines: {node: '>=18'}
408 + cpu: [x64]
409 + os: [darwin]
410 +
411 + '@esbuild/freebsd-arm64@0.28.2':
412 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
413 + engines: {node: '>=18'}
414 + cpu: [arm64]
415 + os: [freebsd]
416 +
417 + '@esbuild/freebsd-x64@0.28.2':
418 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
419 + engines: {node: '>=18'}
420 + cpu: [x64]
421 + os: [freebsd]
422 +
423 + '@esbuild/linux-arm64@0.28.2':
424 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
425 + engines: {node: '>=18'}
426 + cpu: [arm64]
427 + os: [linux]
428 +
429 + '@esbuild/linux-arm@0.28.2':
430 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
431 + engines: {node: '>=18'}
432 + cpu: [arm]
433 + os: [linux]
434 +
435 + '@esbuild/linux-ia32@0.28.2':
436 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
437 + engines: {node: '>=18'}
438 + cpu: [ia32]
439 + os: [linux]
440 +
441 + '@esbuild/linux-loong64@0.28.2':
442 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
443 + engines: {node: '>=18'}
444 + cpu: [loong64]
445 + os: [linux]
446 +
447 + '@esbuild/linux-mips64el@0.28.2':
448 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
449 + engines: {node: '>=18'}
450 + cpu: [mips64el]
451 + os: [linux]
452 +
453 + '@esbuild/linux-ppc64@0.28.2':
454 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
455 + engines: {node: '>=18'}
456 + cpu: [ppc64]
457 + os: [linux]
458 +
459 + '@esbuild/linux-riscv64@0.28.2':
460 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
461 + engines: {node: '>=18'}
462 + cpu: [riscv64]
463 + os: [linux]
464 +
465 + '@esbuild/linux-s390x@0.28.2':
466 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
467 + engines: {node: '>=18'}
468 + cpu: [s390x]
469 + os: [linux]
470 +
471 + '@esbuild/linux-x64@0.28.2':
472 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
473 + engines: {node: '>=18'}
474 + cpu: [x64]
475 + os: [linux]
476 +
477 + '@esbuild/netbsd-arm64@0.28.2':
478 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
479 + engines: {node: '>=18'}
480 + cpu: [arm64]
481 + os: [netbsd]
482 +
483 + '@esbuild/netbsd-x64@0.28.2':
484 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
485 + engines: {node: '>=18'}
486 + cpu: [x64]
487 + os: [netbsd]
488 +
489 + '@esbuild/openbsd-arm64@0.28.2':
490 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
491 + engines: {node: '>=18'}
492 + cpu: [arm64]
493 + os: [openbsd]
494 +
495 + '@esbuild/openbsd-x64@0.28.2':
496 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
497 + engines: {node: '>=18'}
498 + cpu: [x64]
499 + os: [openbsd]
500 +
501 + '@esbuild/openharmony-arm64@0.28.2':
502 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
503 + engines: {node: '>=18'}
504 + cpu: [arm64]
505 + os: [openharmony]
506 +
507 + '@esbuild/sunos-x64@0.28.2':
508 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
509 + engines: {node: '>=18'}
510 + cpu: [x64]
511 + os: [sunos]
512 +
513 + '@esbuild/win32-arm64@0.28.2':
514 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
515 + engines: {node: '>=18'}
516 + cpu: [arm64]
517 + os: [win32]
518 +
519 + '@esbuild/win32-ia32@0.28.2':
520 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
521 + engines: {node: '>=18'}
522 + cpu: [ia32]
523 + os: [win32]
524 +
525 + '@esbuild/win32-x64@0.28.2':
526 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
527 + engines: {node: '>=18'}
528 + cpu: [x64]
529 + os: [win32]
530 +
531 + '@eslint-community/eslint-utils@4.10.1':
532 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
533 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
534 + peerDependencies:
535 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
536 +
537 + '@eslint-community/eslint-utils@4.9.1':
538 + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
539 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
540 + peerDependencies:
541 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
542 +
543 + '@eslint-community/regexpp@4.12.2':
544 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
545 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
546 +
547 + '@eslint/config-array@0.21.2':
548 + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
549 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
550 +
551 + '@eslint/config-helpers@0.4.2':
552 + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
553 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
554 +
555 + '@eslint/core@0.17.0':
556 + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
557 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
558 +
559 + '@eslint/eslintrc@3.3.7':
560 + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==}
561 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
562 +
563 + '@eslint/js@9.39.5':
564 + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
565 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
566 +
567 + '@eslint/object-schema@2.1.7':
568 + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
569 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
570 +
571 + '@eslint/plugin-kit@0.4.1':
572 + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
573 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
574 +
575 + '@fastify/ajv-compiler@4.0.6':
576 + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
577 +
578 + '@fastify/cors@11.3.0':
579 + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
580 +
581 + '@fastify/error@4.2.0':
582 + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
583 +
584 + '@fastify/fast-json-stringify-compiler@5.1.0':
585 + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
586 +
587 + '@fastify/forwarded@3.0.2':
588 + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==}
589 +
590 + '@fastify/merge-json-schemas@0.2.1':
591 + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
592 +
593 + '@fastify/proxy-addr@5.1.0':
594 + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
595 +
596 + '@fastify/rate-limit@10.3.0':
597 + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
598 +
599 + '@fastify/reply-from@12.6.5':
600 + resolution: {integrity: sha512-/4MP+iF6T2+UBEmVYveMU2Tbx3lR1Q7xsDo8GAGjNeQrLF/JqNRlOMhOksh8fXBA47KI8ifsIlUXYdMOUXGZNQ==}
601 +
602 + '@fastify/websocket@11.3.0':
603 + resolution: {integrity: sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==}
604 +
605 + '@humanfs/core@0.19.2':
606 + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
607 + engines: {node: '>=18.18.0'}
608 +
609 + '@humanfs/node@0.16.8':
610 + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
611 + engines: {node: '>=18.18.0'}
612 +
613 + '@humanfs/types@0.15.0':
614 + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
615 + engines: {node: '>=18.18.0'}
616 +
617 + '@humanwhocodes/module-importer@1.0.1':
618 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
619 + engines: {node: '>=12.22'}
620 +
621 + '@humanwhocodes/retry@0.4.3':
622 + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
623 + engines: {node: '>=18.18'}
624 +
625 + '@img/colour@1.1.0':
626 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
627 + engines: {node: '>=18'}
628 +
629 + '@img/sharp-darwin-arm64@0.35.4':
630 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
631 + engines: {node: '>=20.9.0'}
632 + cpu: [arm64]
633 + os: [darwin]
634 +
635 + '@img/sharp-darwin-x64@0.35.4':
636 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
637 + engines: {node: '>=20.9.0'}
638 + cpu: [x64]
639 + os: [darwin]
640 +
641 + '@img/sharp-freebsd-wasm32@0.35.4':
642 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
643 + engines: {node: '>=20.9.0'}
644 + os: [freebsd]
645 +
646 + '@img/sharp-libvips-darwin-arm64@1.3.3':
647 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
648 + cpu: [arm64]
649 + os: [darwin]
650 +
651 + '@img/sharp-libvips-darwin-x64@1.3.3':
652 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
653 + cpu: [x64]
654 + os: [darwin]
655 +
656 + '@img/sharp-libvips-linux-arm64@1.3.3':
657 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
658 + cpu: [arm64]
659 + os: [linux]
660 + libc: [glibc]
661 +
662 + '@img/sharp-libvips-linux-arm@1.3.3':
663 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
664 + cpu: [arm]
665 + os: [linux]
666 + libc: [glibc]
667 +
668 + '@img/sharp-libvips-linux-ppc64@1.3.3':
669 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
670 + cpu: [ppc64]
671 + os: [linux]
672 + libc: [glibc]
673 +
674 + '@img/sharp-libvips-linux-riscv64@1.3.3':
675 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
676 + cpu: [riscv64]
677 + os: [linux]
678 + libc: [glibc]
679 +
680 + '@img/sharp-libvips-linux-s390x@1.3.3':
681 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
682 + cpu: [s390x]
683 + os: [linux]
684 + libc: [glibc]
685 +
686 + '@img/sharp-libvips-linux-x64@1.3.3':
687 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
688 + cpu: [x64]
689 + os: [linux]
690 + libc: [glibc]
691 +
692 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
693 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
694 + cpu: [arm64]
695 + os: [linux]
696 + libc: [musl]
697 +
698 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
699 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
700 + cpu: [x64]
701 + os: [linux]
702 + libc: [musl]
703 +
704 + '@img/sharp-linux-arm64@0.35.4':
705 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
706 + engines: {node: '>=20.9.0'}
707 + cpu: [arm64]
708 + os: [linux]
709 + libc: [glibc]
710 +
711 + '@img/sharp-linux-arm@0.35.4':
712 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
713 + engines: {node: '>=20.9.0'}
714 + cpu: [arm]
715 + os: [linux]
716 + libc: [glibc]
717 +
718 + '@img/sharp-linux-ppc64@0.35.4':
719 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
720 + engines: {node: '>=20.9.0'}
721 + cpu: [ppc64]
722 + os: [linux]
723 + libc: [glibc]
724 +
725 + '@img/sharp-linux-riscv64@0.35.4':
726 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
727 + engines: {node: '>=20.9.0'}
728 + cpu: [riscv64]
729 + os: [linux]
730 + libc: [glibc]
731 +
732 + '@img/sharp-linux-s390x@0.35.4':
733 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
734 + engines: {node: '>=20.9.0'}
735 + cpu: [s390x]
736 + os: [linux]
737 + libc: [glibc]
738 +
739 + '@img/sharp-linux-x64@0.35.4':
740 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
741 + engines: {node: '>=20.9.0'}
742 + cpu: [x64]
743 + os: [linux]
744 + libc: [glibc]
745 +
746 + '@img/sharp-linuxmusl-arm64@0.35.4':
747 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
748 + engines: {node: '>=20.9.0'}
749 + cpu: [arm64]
750 + os: [linux]
751 + libc: [musl]
752 +
753 + '@img/sharp-linuxmusl-x64@0.35.4':
754 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
755 + engines: {node: '>=20.9.0'}
756 + cpu: [x64]
757 + os: [linux]
758 + libc: [musl]
759 +
760 + '@img/sharp-wasm32@0.35.4':
761 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
762 + engines: {node: '>=20.9.0'}
763 +
764 + '@img/sharp-webcontainers-wasm32@0.35.4':
765 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
766 + engines: {node: '>=20.9.0'}
767 + cpu: [wasm32]
768 +
769 + '@img/sharp-win32-arm64@0.35.4':
770 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
771 + engines: {node: '>=20.9.0'}
772 + cpu: [arm64]
773 + os: [win32]
774 +
775 + '@img/sharp-win32-ia32@0.35.4':
776 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
777 + engines: {node: ^20.9.0}
778 + cpu: [ia32]
779 + os: [win32]
780 +
781 + '@img/sharp-win32-x64@0.35.4':
782 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
783 + engines: {node: '>=20.9.0'}
784 + cpu: [x64]
785 + os: [win32]
786 +
787 + '@ioredis/commands@1.10.0':
788 + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
789 +
790 + '@jridgewell/gen-mapping@0.3.13':
791 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
792 +
793 + '@jridgewell/remapping@2.3.5':
794 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
795 +
796 + '@jridgewell/resolve-uri@3.1.2':
797 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
798 + engines: {node: '>=6.0.0'}
799 +
800 + '@jridgewell/sourcemap-codec@1.6.0':
801 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
802 +
803 + '@jridgewell/trace-mapping@0.3.31':
804 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
805 +
806 + '@lukeed/ms@2.0.2':
807 + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
808 + engines: {node: '>=8'}
809 +
810 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
811 + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
812 + engines: {node: ^22.20 || ^24.12 || >=25}
813 + cpu: [x64]
814 + os: [linux]
815 + libc: [glibc]
816 +
817 + '@napi-rs/wasm-runtime@1.2.3':
818 + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
819 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
820 + peerDependencies:
821 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
822 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
823 +
824 + '@next/env@16.3.4':
825 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
826 +
827 + '@next/eslint-plugin-next@16.3.4':
828 + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==}
829 +
830 + '@next/swc-darwin-arm64@16.3.4':
831 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
832 + engines: {node: '>= 10'}
833 + cpu: [arm64]
834 + os: [darwin]
835 +
836 + '@next/swc-darwin-x64@16.3.4':
837 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
838 + engines: {node: '>= 10'}
839 + cpu: [x64]
840 + os: [darwin]
841 +
842 + '@next/swc-linux-arm64-gnu@16.3.4':
843 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
844 + engines: {node: '>= 10'}
845 + cpu: [arm64]
846 + os: [linux]
847 + libc: [glibc]
848 +
849 + '@next/swc-linux-arm64-musl@16.3.4':
850 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
851 + engines: {node: '>= 10'}
852 + cpu: [arm64]
853 + os: [linux]
854 + libc: [musl]
855 +
856 + '@next/swc-linux-x64-gnu@16.3.4':
857 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
858 + engines: {node: '>= 10'}
859 + cpu: [x64]
860 + os: [linux]
861 + libc: [glibc]
862 +
863 + '@next/swc-linux-x64-musl@16.3.4':
864 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
865 + engines: {node: '>= 10'}
866 + cpu: [x64]
867 + os: [linux]
868 + libc: [musl]
869 +
870 + '@next/swc-win32-arm64-msvc@16.3.4':
871 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
872 + engines: {node: '>= 10'}
873 + cpu: [arm64]
874 + os: [win32]
875 +
876 + '@next/swc-win32-x64-msvc@16.3.4':
877 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
878 + engines: {node: '>= 10'}
879 + cpu: [x64]
880 + os: [win32]
881 +
882 + '@nodable/entities@3.0.0':
883 + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
884 +
885 + '@nodelib/fs.scandir@2.1.5':
886 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
887 + engines: {node: '>= 8'}
888 +
889 + '@nodelib/fs.stat@2.0.5':
890 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
891 + engines: {node: '>= 8'}
892 +
893 + '@nodelib/fs.walk@1.2.8':
894 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
895 + engines: {node: '>= 8'}
896 +
897 + '@nolyfill/is-core-module@1.0.39':
898 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
899 + engines: {node: '>=12.4.0'}
900 +
901 + '@opentelemetry/api@1.9.1':
902 + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
903 + engines: {node: '>=8.0.0'}
904 +
905 + '@pinojs/redact@0.4.0':
906 + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
907 +
908 + '@rollup/rollup-android-arm-eabi@4.63.1':
909 + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
910 + cpu: [arm]
911 + os: [android]
912 +
913 + '@rollup/rollup-android-arm64@4.63.1':
914 + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
915 + cpu: [arm64]
916 + os: [android]
917 +
918 + '@rollup/rollup-darwin-arm64@4.63.1':
919 + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
920 + cpu: [arm64]
921 + os: [darwin]
922 +
923 + '@rollup/rollup-darwin-x64@4.63.1':
924 + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
925 + cpu: [x64]
926 + os: [darwin]
927 +
928 + '@rollup/rollup-freebsd-arm64@4.63.1':
929 + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
930 + cpu: [arm64]
931 + os: [freebsd]
932 +
933 + '@rollup/rollup-freebsd-x64@4.63.1':
934 + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
935 + cpu: [x64]
936 + os: [freebsd]
937 +
938 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
939 + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
940 + cpu: [arm]
941 + os: [linux]
942 + libc: [glibc]
943 +
944 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
945 + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
946 + cpu: [arm]
947 + os: [linux]
948 + libc: [musl]
949 +
950 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
951 + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
952 + cpu: [arm64]
953 + os: [linux]
954 + libc: [glibc]
955 +
956 + '@rollup/rollup-linux-arm64-musl@4.63.1':
957 + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
958 + cpu: [arm64]
959 + os: [linux]
960 + libc: [musl]
961 +
962 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
963 + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
964 + cpu: [loong64]
965 + os: [linux]
966 + libc: [glibc]
967 +
968 + '@rollup/rollup-linux-loong64-musl@4.63.1':
969 + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
970 + cpu: [loong64]
971 + os: [linux]
972 + libc: [musl]
973 +
974 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
975 + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
976 + cpu: [ppc64]
977 + os: [linux]
978 + libc: [glibc]
979 +
980 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
981 + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
982 + cpu: [ppc64]
983 + os: [linux]
984 + libc: [musl]
985 +
986 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
987 + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
988 + cpu: [riscv64]
989 + os: [linux]
990 + libc: [glibc]
991 +
992 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
993 + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
994 + cpu: [riscv64]
995 + os: [linux]
996 + libc: [musl]
997 +
998 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
999 + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
1000 + cpu: [s390x]
1001 + os: [linux]
1002 + libc: [glibc]
1003 +
1004 + '@rollup/rollup-linux-x64-gnu@4.63.1':
1005 + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
1006 + cpu: [x64]
1007 + os: [linux]
1008 + libc: [glibc]
1009 +
1010 + '@rollup/rollup-linux-x64-musl@4.63.1':
1011 + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
1012 + cpu: [x64]
1013 + os: [linux]
1014 + libc: [musl]
1015 +
1016 + '@rollup/rollup-openbsd-x64@4.63.1':
1017 + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
1018 + cpu: [x64]
1019 + os: [openbsd]
1020 +
1021 + '@rollup/rollup-openharmony-arm64@4.63.1':
1022 + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
1023 + cpu: [arm64]
1024 + os: [openharmony]
1025 +
1026 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
1027 + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
1028 + cpu: [arm64]
1029 + os: [win32]
1030 +
1031 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
1032 + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
1033 + cpu: [ia32]
1034 + os: [win32]
1035 +
1036 + '@rollup/rollup-win32-x64-gnu@4.63.1':
1037 + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
1038 + cpu: [x64]
1039 + os: [win32]
1040 +
1041 + '@rollup/rollup-win32-x64-msvc@4.63.1':
1042 + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
1043 + cpu: [x64]
1044 + os: [win32]
1045 +
1046 + '@rtsao/scc@1.1.0':
1047 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
1048 +
1049 + '@stablelib/base64@1.0.1':
1050 + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
1051 +
1052 + '@swc/helpers@0.5.23':
1053 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
1054 +
1055 + '@tailwindcss/node@4.3.3':
1056 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
1057 +
1058 + '@tailwindcss/oxide-android-arm64@4.3.3':
1059 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
1060 + engines: {node: '>= 20'}
1061 + cpu: [arm64]
1062 + os: [android]
1063 +
1064 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
1065 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
1066 + engines: {node: '>= 20'}
1067 + cpu: [arm64]
1068 + os: [darwin]
1069 +
1070 + '@tailwindcss/oxide-darwin-x64@4.3.3':
1071 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
1072 + engines: {node: '>= 20'}
1073 + cpu: [x64]
1074 + os: [darwin]
1075 +
1076 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
1077 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
1078 + engines: {node: '>= 20'}
1079 + cpu: [x64]
1080 + os: [freebsd]
1081 +
1082 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
1083 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
1084 + engines: {node: '>= 20'}
1085 + cpu: [arm]
1086 + os: [linux]
1087 +
1088 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
1089 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
1090 + engines: {node: '>= 20'}
1091 + cpu: [arm64]
1092 + os: [linux]
1093 + libc: [glibc]
1094 +
1095 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
1096 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
1097 + engines: {node: '>= 20'}
1098 + cpu: [arm64]
1099 + os: [linux]
1100 + libc: [musl]
1101 +
1102 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
1103 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
1104 + engines: {node: '>= 20'}
1105 + cpu: [x64]
1106 + os: [linux]
1107 + libc: [glibc]
1108 +
1109 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
1110 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
1111 + engines: {node: '>= 20'}
1112 + cpu: [x64]
1113 + os: [linux]
1114 + libc: [musl]
1115 +
1116 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
1117 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
1118 + engines: {node: '>=14.0.0'}
1119 + cpu: [wasm32]
1120 + bundledDependencies:
1121 + - '@napi-rs/wasm-runtime'
1122 + - '@emnapi/core'
1123 + - '@emnapi/runtime'
1124 + - '@tybys/wasm-util'
1125 + - '@emnapi/wasi-threads'
1126 + - tslib
1127 +
1128 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
1129 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
1130 + engines: {node: '>= 20'}
1131 + cpu: [arm64]
1132 + os: [win32]
1133 +
1134 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
1135 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
1136 + engines: {node: '>= 20'}
1137 + cpu: [x64]
1138 + os: [win32]
1139 +
1140 + '@tailwindcss/oxide@4.3.3':
1141 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
1142 + engines: {node: '>= 20'}
1143 +
1144 + '@tailwindcss/postcss@4.3.3':
1145 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
1146 +
1147 + '@tybys/wasm-util@0.10.3':
1148 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
1149 +
1150 + '@types/chai@5.2.3':
1151 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
1152 +
1153 + '@types/deep-eql@4.0.2':
1154 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
1155 +
1156 + '@types/estree@1.0.9':
1157 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
1158 +
1159 + '@types/json-schema@7.0.15':
1160 + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
1161 +
1162 + '@types/json5@0.0.29':
1163 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
1164 +
1165 + '@types/node@24.13.3':
1166 + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
1167 +
1168 + '@types/pg@8.23.1':
1169 + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
1170 +
1171 + '@types/react-dom@19.2.7':
1172 + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
1173 + peerDependencies:
1174 + '@types/react': ^19.2.0
1175 +
1176 + '@types/react@19.2.18':
1177 + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
1178 +
1179 + '@types/ws@8.18.1':
1180 + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
1181 +
1182 + '@typescript-eslint/eslint-plugin@8.69.0':
1183 + resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
1184 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1185 + peerDependencies:
1186 + '@typescript-eslint/parser': ^8.69.0
1187 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1188 + typescript: '>=4.8.4 <6.1.0'
1189 +
1190 + '@typescript-eslint/parser@8.69.0':
1191 + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==}
1192 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1193 + peerDependencies:
1194 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1195 + typescript: '>=4.8.4 <6.1.0'
1196 +
1197 + '@typescript-eslint/project-service@8.69.0':
1198 + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==}
1199 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1200 + peerDependencies:
1201 + typescript: '>=4.8.4 <6.1.0'
1202 +
1203 + '@typescript-eslint/scope-manager@8.69.0':
1204 + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==}
1205 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1206 +
1207 + '@typescript-eslint/tsconfig-utils@8.69.0':
1208 + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==}
1209 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1210 + peerDependencies:
1211 + typescript: '>=4.8.4 <6.1.0'
1212 +
1213 + '@typescript-eslint/type-utils@8.69.0':
1214 + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==}
1215 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1216 + peerDependencies:
1217 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1218 + typescript: '>=4.8.4 <6.1.0'
1219 +
1220 + '@typescript-eslint/types@8.69.0':
1221 + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==}
1222 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1223 +
1224 + '@typescript-eslint/typescript-estree@8.69.0':
1225 + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==}
1226 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1227 + peerDependencies:
1228 + typescript: '>=4.8.4 <6.1.0'
1229 +
1230 + '@typescript-eslint/utils@8.69.0':
1231 + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==}
1232 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1233 + peerDependencies:
1234 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1235 + typescript: '>=4.8.4 <6.1.0'
1236 +
1237 + '@typescript-eslint/visitor-keys@8.69.0':
1238 + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
1239 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1240 +
1241 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
1242 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
1243 + cpu: [arm]
1244 + os: [android]
1245 +
1246 + '@unrs/resolver-binding-android-arm64@1.12.2':
1247 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
1248 + cpu: [arm64]
1249 + os: [android]
1250 +
1251 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
1252 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
1253 + cpu: [arm64]
1254 + os: [darwin]
1255 +
1256 + '@unrs/resolver-binding-darwin-x64@1.12.2':
1257 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
1258 + cpu: [x64]
1259 + os: [darwin]
1260 +
1261 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
1262 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
1263 + cpu: [x64]
1264 + os: [freebsd]
1265 +
1266 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
1267 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
1268 + cpu: [arm]
1269 + os: [linux]
1270 +
1271 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
1272 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
1273 + cpu: [arm]
1274 + os: [linux]
1275 +
1276 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
1277 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
1278 + cpu: [arm64]
1279 + os: [linux]
1280 + libc: [glibc]
1281 +
1282 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
1283 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
1284 + cpu: [arm64]
1285 + os: [linux]
1286 + libc: [musl]
1287 +
1288 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
1289 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
1290 + cpu: [loong64]
1291 + os: [linux]
1292 + libc: [glibc]
1293 +
1294 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
1295 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
1296 + cpu: [loong64]
1297 + os: [linux]
1298 + libc: [musl]
1299 +
1300 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
1301 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
1302 + cpu: [ppc64]
1303 + os: [linux]
1304 + libc: [glibc]
1305 +
1306 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
1307 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
1308 + cpu: [riscv64]
1309 + os: [linux]
1310 + libc: [glibc]
1311 +
1312 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
1313 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
1314 + cpu: [riscv64]
1315 + os: [linux]
1316 + libc: [musl]
1317 +
1318 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
1319 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
1320 + cpu: [s390x]
1321 + os: [linux]
1322 + libc: [glibc]
1323 +
1324 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
1325 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
1326 + cpu: [x64]
1327 + os: [linux]
1328 + libc: [glibc]
1329 +
1330 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
1331 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
1332 + cpu: [x64]
1333 + os: [linux]
1334 + libc: [musl]
1335 +
1336 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
1337 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
1338 + cpu: [arm64]
1339 + os: [openharmony]
1340 +
1341 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
1342 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
1343 + engines: {node: '>=14.0.0'}
1344 + cpu: [wasm32]
1345 +
1346 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
1347 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
1348 + cpu: [arm64]
1349 + os: [win32]
1350 +
1351 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
1352 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
1353 + cpu: [ia32]
1354 + os: [win32]
1355 +
1356 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
1357 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
1358 + cpu: [x64]
1359 + os: [win32]
1360 +
1361 + '@vitest/expect@3.2.7':
1362 + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
1363 +
1364 + '@vitest/mocker@3.2.7':
1365 + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
1366 + peerDependencies:
1367 + msw: ^2.4.9
1368 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
1369 + peerDependenciesMeta:
1370 + msw:
1371 + optional: true
1372 + vite:
1373 + optional: true
1374 +
1375 + '@vitest/pretty-format@3.2.7':
1376 + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
1377 +
1378 + '@vitest/runner@3.2.7':
1379 + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
1380 +
1381 + '@vitest/snapshot@3.2.7':
1382 + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
1383 +
1384 + '@vitest/spy@3.2.7':
1385 + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
1386 +
1387 + '@vitest/utils@3.2.7':
1388 + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
1389 +
1390 + abstract-logging@2.0.1:
1391 + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
1392 +
1393 + acorn-jsx@5.3.2:
1394 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1395 + peerDependencies:
1396 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
1397 +
1398 + acorn@8.18.0:
1399 + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
1400 + engines: {node: '>=0.4.0'}
1401 + hasBin: true
1402 +
1403 + ajv-formats@3.0.1:
1404 + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
1405 + peerDependencies:
1406 + ajv: ^8.0.0
1407 + peerDependenciesMeta:
1408 + ajv:
1409 + optional: true
1410 +
1411 + ajv@6.15.0:
1412 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
1413 +
1414 + ajv@8.20.0:
1415 + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
1416 +
1417 + ansi-styles@4.3.0:
1418 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
1419 + engines: {node: '>=8'}
1420 +
1421 + anynum@1.0.1:
1422 + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
1423 +
1424 + argparse@2.0.1:
1425 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
1426 +
1427 + aria-query@5.3.2:
1428 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
1429 + engines: {node: '>= 0.4'}
1430 +
1431 + array-buffer-byte-length@1.0.2:
1432 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
1433 + engines: {node: '>= 0.4'}
1434 +
1435 + array-includes@3.1.9:
1436 + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
1437 + engines: {node: '>= 0.4'}
1438 +
1439 + array.prototype.findlast@1.2.5:
1440 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
1441 + engines: {node: '>= 0.4'}
1442 +
1443 + array.prototype.findlastindex@1.2.6:
1444 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
1445 + engines: {node: '>= 0.4'}
1446 +
1447 + array.prototype.flat@1.3.3:
1448 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
1449 + engines: {node: '>= 0.4'}
1450 +
1451 + array.prototype.flatmap@1.3.3:
1452 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
1453 + engines: {node: '>= 0.4'}
1454 +
1455 + array.prototype.tosorted@1.1.4:
1456 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
1457 + engines: {node: '>= 0.4'}
1458 +
1459 + arraybuffer.prototype.slice@1.0.4:
1460 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
1461 + engines: {node: '>= 0.4'}
1462 +
1463 + assertion-error@2.0.1:
1464 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1465 + engines: {node: '>=12'}
1466 +
1467 + ast-types-flow@0.0.8:
1468 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
1469 +
1470 + async-function@1.0.0:
1471 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
1472 + engines: {node: '>= 0.4'}
1473 +
1474 + atomic-sleep@1.0.0:
1475 + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
1476 + engines: {node: '>=8.0.0'}
1477 +
1478 + available-typed-arrays@1.0.7:
1479 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
1480 + engines: {node: '>= 0.4'}
1481 +
1482 + avvio@9.3.0:
1483 + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
1484 +
1485 + axe-core@4.13.0:
1486 + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
1487 + engines: {node: '>=4'}
1488 +
1489 + axobject-query@4.1.0:
1490 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
1491 + engines: {node: '>= 0.4'}
1492 +
1493 + balanced-match@1.0.2:
1494 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
1495 +
1496 + balanced-match@4.0.4:
1497 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
1498 + engines: {node: 18 || 20 || >=22}
1499 +
1500 + baseline-browser-mapping@2.11.21:
1501 + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
1502 + engines: {node: '>=6.0.0'}
1503 + hasBin: true
1504 +
1505 + bintrees@1.0.2:
1506 + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
1507 +
1508 + boolbase@1.0.0:
1509 + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
1510 +
1511 + brace-expansion@1.1.18:
1512 + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
1513 +
1514 + brace-expansion@5.0.9:
1515 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
1516 + engines: {node: 20 || >=22}
1517 +
1518 + braces@3.0.3:
1519 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
1520 + engines: {node: '>=8'}
1521 +
1522 + browserslist@4.28.9:
1523 + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
1524 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
1525 + hasBin: true
1526 +
1527 + cac@6.7.14:
1528 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
1529 + engines: {node: '>=8'}
1530 +
1531 + call-bind-apply-helpers@1.0.2:
1532 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
1533 + engines: {node: '>= 0.4'}
1534 +
1535 + call-bind@1.0.9:
1536 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
1537 + engines: {node: '>= 0.4'}
1538 +
1539 + call-bound@1.0.4:
1540 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
1541 + engines: {node: '>= 0.4'}
1542 +
1543 + callsites@3.1.0:
1544 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
1545 + engines: {node: '>=6'}
1546 +
1547 + caniuse-lite@1.0.30001810:
1548 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
1549 +
1550 + chai@5.3.3:
1551 + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
1552 + engines: {node: '>=18'}
1553 +
1554 + chalk@4.1.2:
1555 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
1556 + engines: {node: '>=10'}
1557 +
1558 + check-error@2.1.3:
1559 + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
1560 + engines: {node: '>= 16'}
1561 +
1562 + cheerio-select@2.1.0:
1563 + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
1564 +
1565 + cheerio@1.2.0:
1566 + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
1567 + engines: {node: '>=20.18.1'}
1568 +
1569 + client-only@0.0.1:
1570 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1571 +
1572 + cluster-key-slot@1.1.1:
1573 + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
1574 + engines: {node: '>=0.10.0'}
1575 +
1576 + color-convert@2.0.1:
1577 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
1578 + engines: {node: '>=7.0.0'}
1579 +
1580 + color-name@1.1.4:
1581 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
1582 +
1583 + colorette@2.0.20:
1584 + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
1585 +
1586 + concat-map@0.0.1:
1587 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
1588 +
1589 + convert-source-map@2.0.0:
1590 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
1591 +
1592 + cookie@1.1.1:
1593 + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
1594 + engines: {node: '>=18'}
1595 +
1596 + cross-spawn@7.0.6:
1597 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1598 + engines: {node: '>= 8'}
1599 +
1600 + css-select@5.2.2:
1601 + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
1602 +
1603 + css-what@6.2.2:
1604 + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
1605 + engines: {node: '>= 6'}
1606 +
1607 + csstype@3.2.3:
1608 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1609 +
1610 + damerau-levenshtein@1.0.8:
1611 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
1612 +
1613 + data-view-buffer@1.0.2:
1614 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
1615 + engines: {node: '>= 0.4'}
1616 +
1617 + data-view-byte-length@1.0.2:
1618 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
1619 + engines: {node: '>= 0.4'}
1620 +
1621 + data-view-byte-offset@1.0.1:
1622 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
1623 + engines: {node: '>= 0.4'}
1624 +
1625 + dateformat@4.6.3:
1626 + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
1627 +
1628 + debug@3.2.7:
1629 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
1630 + peerDependencies:
1631 + supports-color: '*'
1632 + peerDependenciesMeta:
1633 + supports-color:
1634 + optional: true
1635 +
1636 + debug@4.4.3:
1637 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1638 + engines: {node: '>=6.0'}
1639 + peerDependencies:
1640 + supports-color: '*'
1641 + peerDependenciesMeta:
1642 + supports-color:
1643 + optional: true
1644 +
1645 + deep-eql@5.0.2:
1646 + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
1647 + engines: {node: '>=6'}
1648 +
1649 + deep-is@0.1.4:
1650 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
1651 +
1652 + define-data-property@1.1.4:
1653 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
1654 + engines: {node: '>= 0.4'}
1655 +
1656 + define-properties@1.2.1:
1657 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1658 + engines: {node: '>= 0.4'}
1659 +
1660 + denque@2.1.0:
1661 + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
1662 + engines: {node: '>=0.10'}
1663 +
1664 + dequal@2.0.3:
1665 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1666 + engines: {node: '>=6'}
1667 +
1668 + detect-libc@2.1.2:
1669 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1670 + engines: {node: '>=8'}
1671 +
1672 + diff@8.0.4:
1673 + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
1674 + engines: {node: '>=0.3.1'}
1675 +
1676 + doctrine@2.1.0:
1677 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
1678 + engines: {node: '>=0.10.0'}
1679 +
1680 + dom-serializer@2.0.0:
1681 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
1682 +
1683 + domelementtype@2.3.0:
1684 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
1685 +
1686 + domhandler@5.0.3:
1687 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
1688 + engines: {node: '>= 4'}
1689 +
1690 + domutils@3.2.2:
1691 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
1692 +
1693 + drizzle-orm@0.45.2:
1694 + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
1695 + peerDependencies:
1696 + '@aws-sdk/client-rds-data': '>=3'
1697 + '@cloudflare/workers-types': '>=4'
1698 + '@electric-sql/pglite': '>=0.2.0'
1699 + '@libsql/client': '>=0.10.0'
1700 + '@libsql/client-wasm': '>=0.10.0'
1701 + '@neondatabase/serverless': '>=0.10.0'
1702 + '@op-engineering/op-sqlite': '>=2'
1703 + '@opentelemetry/api': ^1.4.1
1704 + '@planetscale/database': '>=1.13'
1705 + '@prisma/client': '*'
1706 + '@tidbcloud/serverless': '*'
1707 + '@types/better-sqlite3': '*'
1708 + '@types/pg': '*'
1709 + '@types/sql.js': '*'
1710 + '@upstash/redis': '>=1.34.7'
1711 + '@vercel/postgres': '>=0.8.0'
1712 + '@xata.io/client': '*'
1713 + better-sqlite3: '>=7'
1714 + bun-types: '*'
1715 + expo-sqlite: '>=14.0.0'
1716 + gel: '>=2'
1717 + knex: '*'
1718 + kysely: '*'
1719 + mysql2: '>=2'
1720 + pg: '>=8'
1721 + postgres: '>=3'
1722 + prisma: '*'
1723 + sql.js: '>=1'
1724 + sqlite3: '>=5'
1725 + peerDependenciesMeta:
1726 + '@aws-sdk/client-rds-data':
1727 + optional: true
1728 + '@cloudflare/workers-types':
1729 + optional: true
1730 + '@electric-sql/pglite':
1731 + optional: true
1732 + '@libsql/client':
1733 + optional: true
1734 + '@libsql/client-wasm':
1735 + optional: true
1736 + '@neondatabase/serverless':
1737 + optional: true
1738 + '@op-engineering/op-sqlite':
1739 + optional: true
1740 + '@opentelemetry/api':
1741 + optional: true
1742 + '@planetscale/database':
1743 + optional: true
1744 + '@prisma/client':
1745 + optional: true
1746 + '@tidbcloud/serverless':
1747 + optional: true
1748 + '@types/better-sqlite3':
1749 + optional: true
1750 + '@types/pg':
1751 + optional: true
1752 + '@types/sql.js':
1753 + optional: true
1754 + '@upstash/redis':
1755 + optional: true
1756 + '@vercel/postgres':
1757 + optional: true
1758 + '@xata.io/client':
1759 + optional: true
1760 + better-sqlite3:
1761 + optional: true
1762 + bun-types:
1763 + optional: true
1764 + expo-sqlite:
1765 + optional: true
1766 + gel:
1767 + optional: true
1768 + knex:
1769 + optional: true
1770 + kysely:
1771 + optional: true
1772 + mysql2:
1773 + optional: true
1774 + pg:
1775 + optional: true
1776 + postgres:
1777 + optional: true
1778 + prisma:
1779 + optional: true
1780 + sql.js:
1781 + optional: true
1782 + sqlite3:
1783 + optional: true
1784 +
1785 + dunder-proto@1.0.1:
1786 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1787 + engines: {node: '>= 0.4'}
1788 +
1789 + duplexify@4.1.3:
1790 + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
1791 +
1792 + electron-to-chromium@1.5.422:
1793 + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==}
1794 +
1795 + emoji-regex@9.2.2:
1796 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
1797 +
1798 + encoding-sniffer@0.2.1:
1799 + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
1800 +
1801 + end-of-stream@1.4.5:
1802 + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
1803 +
1804 + enhanced-resolve@5.24.5:
1805 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
1806 + engines: {node: '>=10.13.0'}
1807 +
1808 + entities@4.5.0:
1809 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
1810 + engines: {node: '>=0.12'}
1811 +
1812 + entities@6.0.1:
1813 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
1814 + engines: {node: '>=0.12'}
1815 +
1816 + entities@7.0.1:
1817 + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
1818 + engines: {node: '>=0.12'}
1819 +
1820 + es-abstract-get@1.0.0:
1821 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
1822 + engines: {node: '>= 0.4'}
1823 +
1824 + es-abstract@1.24.2:
1825 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
1826 + engines: {node: '>= 0.4'}
1827 +
1828 + es-define-property@1.0.1:
1829 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
1830 + engines: {node: '>= 0.4'}
1831 +
1832 + es-errors@1.3.0:
1833 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
1834 + engines: {node: '>= 0.4'}
1835 +
1836 + es-iterator-helpers@1.4.0:
1837 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
1838 + engines: {node: '>= 0.4'}
1839 +
1840 + es-module-lexer@1.7.0:
1841 + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
1842 +
1843 + es-object-atoms@1.1.2:
1844 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
1845 + engines: {node: '>= 0.4'}
1846 +
1847 + es-set-tostringtag@2.1.0:
1848 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
1849 + engines: {node: '>= 0.4'}
1850 +
1851 + es-shim-unscopables@1.1.0:
1852 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
1853 + engines: {node: '>= 0.4'}
1854 +
1855 + es-to-primitive@1.3.4:
1856 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
1857 + engines: {node: '>= 0.4'}
1858 +
1859 + esbuild@0.28.2:
1860 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
1861 + engines: {node: '>=18'}
1862 + hasBin: true
1863 +
1864 + escalade@3.2.0:
1865 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1866 + engines: {node: '>=6'}
1867 +
1868 + escape-string-regexp@4.0.0:
1869 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
1870 + engines: {node: '>=10'}
1871 +
1872 + eslint-config-next@16.3.4:
1873 + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==}
1874 + peerDependencies:
1875 + eslint: '>=9.0.0'
1876 + typescript: '>=3.3.1'
1877 + peerDependenciesMeta:
1878 + typescript:
1879 + optional: true
1880 +
1881 + eslint-import-resolver-node@0.3.10:
1882 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
1883 +
1884 + eslint-import-resolver-typescript@3.10.1:
1885 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
1886 + engines: {node: ^14.18.0 || >=16.0.0}
1887 + peerDependencies:
1888 + eslint: '*'
1889 + eslint-plugin-import: '*'
1890 + eslint-plugin-import-x: '*'
1891 + peerDependenciesMeta:
1892 + eslint-plugin-import:
1893 + optional: true
1894 + eslint-plugin-import-x:
1895 + optional: true
1896 +
1897 + eslint-module-utils@2.14.0:
1898 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
1899 + engines: {node: '>=4'}
1900 + peerDependencies:
1901 + '@typescript-eslint/parser': '*'
1902 + eslint: '*'
1903 + eslint-import-resolver-node: '*'
1904 + eslint-import-resolver-typescript: '*'
1905 + eslint-import-resolver-webpack: '*'
1906 + peerDependenciesMeta:
1907 + '@typescript-eslint/parser':
1908 + optional: true
1909 + eslint:
1910 + optional: true
1911 + eslint-import-resolver-node:
1912 + optional: true
1913 + eslint-import-resolver-typescript:
1914 + optional: true
1915 + eslint-import-resolver-webpack:
1916 + optional: true
1917 +
1918 + eslint-plugin-import@2.32.0:
1919 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
1920 + engines: {node: '>=4'}
1921 + peerDependencies:
1922 + '@typescript-eslint/parser': '*'
1923 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
1924 + peerDependenciesMeta:
1925 + '@typescript-eslint/parser':
1926 + optional: true
1927 +
1928 + eslint-plugin-jsx-a11y@6.10.2:
1929 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
1930 + engines: {node: '>=4.0'}
1931 + peerDependencies:
1932 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
1933 +
1934 + eslint-plugin-react-hooks@7.1.1:
1935 + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
1936 + engines: {node: '>=18'}
1937 + peerDependencies:
1938 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
1939 +
1940 + eslint-plugin-react@7.37.5:
1941 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
1942 + engines: {node: '>=4'}
1943 + peerDependencies:
1944 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
1945 +
1946 + eslint-scope@8.4.0:
1947 + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
1948 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1949 +
1950 + eslint-visitor-keys@3.4.3:
1951 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
1952 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1953 +
1954 + eslint-visitor-keys@4.2.1:
1955 + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
1956 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1957 +
1958 + eslint-visitor-keys@5.0.1:
1959 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
1960 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
1961 +
1962 + eslint@9.39.5:
1963 + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
1964 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1965 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
1966 + hasBin: true
1967 + peerDependencies:
1968 + jiti: '*'
1969 + peerDependenciesMeta:
1970 + jiti:
1971 + optional: true
1972 +
1973 + espree@10.4.0:
1974 + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
1975 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1976 +
1977 + esquery@1.7.0:
1978 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
1979 + engines: {node: '>=0.10'}
1980 +
1981 + esrecurse@4.3.0:
1982 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
1983 + engines: {node: '>=4.0'}
1984 +
1985 + estraverse@5.3.0:
1986 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1987 + engines: {node: '>=4.0'}
1988 +
1989 + estree-walker@3.0.3:
1990 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1991 +
1992 + esutils@2.0.3:
1993 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1994 + engines: {node: '>=0.10.0'}
1995 +
1996 + expect-type@1.4.0:
1997 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
1998 + engines: {node: '>=12.0.0'}
1999 +
2000 + fast-content-type-parse@3.0.0:
2001 + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
2002 +
2003 + fast-copy@4.1.1:
2004 + resolution: {integrity: sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==}
2005 +
2006 + fast-decode-uri-component@1.0.1:
2007 + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
2008 +
2009 + fast-deep-equal@3.1.3:
2010 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
2011 +
2012 + fast-glob@3.3.1:
2013 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
2014 + engines: {node: '>=8.6.0'}
2015 +
2016 + fast-json-stable-stringify@2.1.0:
2017 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
2018 +
2019 + fast-json-stringify@7.0.1:
2020 + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==}
2021 +
2022 + fast-levenshtein@2.0.6:
2023 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
2024 +
2025 + fast-querystring@1.1.2:
2026 + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
2027 +
2028 + fast-safe-stringify@2.1.1:
2029 + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
2030 +
2031 + fast-sha256@1.3.0:
2032 + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
2033 +
2034 + fast-uri@3.1.7:
2035 + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
2036 +
2037 + fast-uri@4.1.4:
2038 + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==}
2039 +
2040 + fast-xml-builder@1.3.1:
2041 + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
2042 +
2043 + fast-xml-parser@5.11.1:
2044 + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==}
2045 + hasBin: true
2046 +
2047 + fastify-plugin@5.1.0:
2048 + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
2049 +
2050 + fastify-plugin@6.0.0:
2051 + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==}
2052 +
2053 + fastify@5.12.3:
2054 + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==}
2055 +
2056 + fastq@1.20.3:
2057 + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
2058 +
2059 + fdir@6.5.0:
2060 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
2061 + engines: {node: '>=12.0.0'}
2062 + peerDependencies:
2063 + picomatch: ^3 || ^4
2064 + peerDependenciesMeta:
2065 + picomatch:
2066 + optional: true
2067 +
2068 + file-entry-cache@8.0.0:
2069 + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
2070 + engines: {node: '>=16.0.0'}
2071 +
2072 + fill-range@7.1.1:
2073 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
2074 + engines: {node: '>=8'}
2075 +
2076 + find-my-way@9.9.0:
2077 + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
2078 + engines: {node: '>=20'}
2079 +
2080 + find-up@5.0.0:
2081 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
2082 + engines: {node: '>=10'}
2083 +
2084 + flat-cache@4.0.1:
2085 + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
2086 + engines: {node: '>=16'}
2087 +
2088 + flatted@3.4.4:
2089 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
2090 +
2091 + for-each@0.3.5:
2092 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
2093 + engines: {node: '>= 0.4'}
2094 +
2095 + fsevents@2.3.3:
2096 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
2097 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
2098 + os: [darwin]
2099 +
2100 + function-bind@1.1.2:
2101 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
2102 +
2103 + function.prototype.name@1.2.0:
2104 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
2105 + engines: {node: '>= 0.4'}
2106 +
2107 + functions-have-names@1.2.3:
2108 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
2109 +
2110 + generator-function@2.0.1:
2111 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
2112 + engines: {node: '>= 0.4'}
2113 +
2114 + gensync@1.0.0-beta.2:
2115 + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
2116 + engines: {node: '>=6.9.0'}
2117 +
2118 + get-intrinsic@1.3.0:
2119 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
2120 + engines: {node: '>= 0.4'}
2121 +
2122 + get-proto@1.0.1:
2123 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
2124 + engines: {node: '>= 0.4'}
2125 +
2126 + get-symbol-description@1.1.0:
2127 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
2128 + engines: {node: '>= 0.4'}
2129 +
2130 + get-tsconfig@4.14.3:
2131 + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
2132 +
2133 + glob-parent@5.1.2:
2134 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
2135 + engines: {node: '>= 6'}
2136 +
2137 + glob-parent@6.0.2:
2138 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
2139 + engines: {node: '>=10.13.0'}
2140 +
2141 + globals@14.0.0:
2142 + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
2143 + engines: {node: '>=18'}
2144 +
2145 + globals@16.4.0:
2146 + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
2147 + engines: {node: '>=18'}
2148 +
2149 + globalthis@1.0.4:
2150 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
2151 + engines: {node: '>= 0.4'}
2152 +
2153 + gopd@1.2.0:
2154 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
2155 + engines: {node: '>= 0.4'}
2156 +
2157 + graceful-fs@4.2.11:
2158 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
2159 +
2160 + has-bigints@1.1.0:
2161 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
2162 + engines: {node: '>= 0.4'}
2163 +
2164 + has-flag@4.0.0:
2165 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
2166 + engines: {node: '>=8'}
2167 +
2168 + has-property-descriptors@1.0.2:
2169 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
2170 +
2171 + has-proto@1.2.0:
2172 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
2173 + engines: {node: '>= 0.4'}
2174 +
2175 + has-symbols@1.1.0:
2176 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
2177 + engines: {node: '>= 0.4'}
2178 +
2179 + has-tostringtag@1.0.2:
2180 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
2181 + engines: {node: '>= 0.4'}
2182 +
2183 + hasown@2.0.4:
2184 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
2185 + engines: {node: '>= 0.4'}
2186 +
2187 + help-me@5.0.0:
2188 + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
2189 +
2190 + hermes-estree@0.25.1:
2191 + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
2192 +
2193 + hermes-parser@0.25.1:
2194 + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
2195 +
2196 + htmlparser2@10.1.0:
2197 + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
2198 +
2199 + iconv-lite@0.6.3:
2200 + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
2201 + engines: {node: '>=0.10.0'}
2202 +
2203 + ignore@5.3.2:
2204 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
2205 + engines: {node: '>= 4'}
2206 +
2207 + ignore@7.0.8:
2208 + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==}
2209 + engines: {node: '>= 4'}
2210 +
2211 + import-fresh@3.3.1:
2212 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
2213 + engines: {node: '>=6'}
2214 +
2215 + imurmurhash@0.1.4:
2216 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
2217 + engines: {node: '>=0.8.19'}
2218 +
2219 + inherits@2.0.4:
2220 + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
2221 +
2222 + internal-slot@1.1.0:
2223 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
2224 + engines: {node: '>= 0.4'}
2225 +
2226 + ioredis@5.11.1:
2227 + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
2228 + engines: {node: '>=12.22.0'}
2229 +
2230 + ipaddr.js@2.5.0:
2231 + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
2232 + engines: {node: '>= 10'}
2233 +
2234 + is-array-buffer@3.0.5:
2235 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
2236 + engines: {node: '>= 0.4'}
2237 +
2238 + is-async-function@2.1.1:
2239 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
2240 + engines: {node: '>= 0.4'}
2241 +
2242 + is-bigint@1.1.0:
2243 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
2244 + engines: {node: '>= 0.4'}
2245 +
2246 + is-boolean-object@1.2.2:
2247 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
2248 + engines: {node: '>= 0.4'}
2249 +
2250 + is-bun-module@2.0.0:
2251 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
2252 +
2253 + is-callable@1.2.7:
2254 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
2255 + engines: {node: '>= 0.4'}
2256 +
2257 + is-core-module@2.16.2:
2258 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
2259 + engines: {node: '>= 0.4'}
2260 +
2261 + is-data-view@1.0.2:
2262 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
2263 + engines: {node: '>= 0.4'}
2264 +
2265 + is-date-object@1.1.0:
2266 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
2267 + engines: {node: '>= 0.4'}
2268 +
2269 + is-document.all@1.0.0:
2270 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
2271 + engines: {node: '>= 0.4'}
2272 +
2273 + is-extglob@2.1.1:
2274 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
2275 + engines: {node: '>=0.10.0'}
2276 +
2277 + is-finalizationregistry@1.1.1:
2278 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
2279 + engines: {node: '>= 0.4'}
2280 +
2281 + is-generator-function@1.1.2:
2282 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
2283 + engines: {node: '>= 0.4'}
2284 +
2285 + is-glob@4.0.3:
2286 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
2287 + engines: {node: '>=0.10.0'}
2288 +
2289 + is-map@2.0.3:
2290 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
2291 + engines: {node: '>= 0.4'}
2292 +
2293 + is-negative-zero@2.0.3:
2294 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
2295 + engines: {node: '>= 0.4'}
2296 +
2297 + is-number-object@1.1.1:
2298 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
2299 + engines: {node: '>= 0.4'}
2300 +
2301 + is-number@7.0.0:
2302 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
2303 + engines: {node: '>=0.12.0'}
2304 +
2305 + is-regex@1.2.1:
2306 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
2307 + engines: {node: '>= 0.4'}
2308 +
2309 + is-set@2.0.3:
2310 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
2311 + engines: {node: '>= 0.4'}
2312 +
2313 + is-shared-array-buffer@1.0.4:
2314 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
2315 + engines: {node: '>= 0.4'}
2316 +
2317 + is-string@1.1.1:
2318 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
2319 + engines: {node: '>= 0.4'}
2320 +
2321 + is-symbol@1.1.1:
2322 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
2323 + engines: {node: '>= 0.4'}
2324 +
2325 + is-typed-array@1.1.15:
2326 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
2327 + engines: {node: '>= 0.4'}
2328 +
2329 + is-unsafe@2.0.2:
2330 + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
2331 +
2332 + is-weakmap@2.0.2:
2333 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
2334 + engines: {node: '>= 0.4'}
2335 +
2336 + is-weakref@1.1.1:
2337 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
2338 + engines: {node: '>= 0.4'}
2339 +
2340 + is-weakset@2.0.4:
2341 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
2342 + engines: {node: '>= 0.4'}
2343 +
2344 + isarray@2.0.5:
2345 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
2346 +
2347 + isexe@2.0.0:
2348 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
2349 +
2350 + iterator.prototype@1.1.5:
2351 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
2352 + engines: {node: '>= 0.4'}
2353 +
2354 + jiti@2.7.0:
2355 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
2356 + hasBin: true
2357 +
2358 + joycon@3.1.1:
2359 + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
2360 + engines: {node: '>=10'}
2361 +
2362 + js-tokens@4.0.0:
2363 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
2364 +
2365 + js-tokens@9.0.1:
2366 + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
2367 +
2368 + js-yaml@4.3.2:
2369 + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
2370 + hasBin: true
2371 +
2372 + jsesc@3.1.0:
2373 + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
2374 + engines: {node: '>=6'}
2375 + hasBin: true
2376 +
2377 + json-buffer@3.0.1:
2378 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
2379 +
2380 + json-schema-ref-resolver@3.0.0:
2381 + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
2382 +
2383 + json-schema-to-ts@3.1.1:
2384 + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
2385 + engines: {node: '>=16'}
2386 +
2387 + json-schema-traverse@0.4.1:
2388 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
2389 +
2390 + json-schema-traverse@1.0.0:
2391 + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
2392 +
2393 + json-stable-stringify-without-jsonify@1.0.1:
2394 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
2395 +
2396 + json5@1.0.2:
2397 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
2398 + hasBin: true
2399 +
2400 + json5@2.2.3:
2401 + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
2402 + engines: {node: '>=6'}
2403 + hasBin: true
2404 +
2405 + jsx-ast-utils@3.3.5:
2406 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
2407 + engines: {node: '>=4.0'}
2408 +
2409 + keyv@4.5.4:
2410 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
2411 +
2412 + language-subtag-registry@0.3.23:
2413 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
2414 +
2415 + language-tags@1.0.9:
2416 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
2417 + engines: {node: '>=0.10'}
2418 +
2419 + levn@0.4.1:
2420 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
2421 + engines: {node: '>= 0.8.0'}
2422 +
2423 + light-my-request@6.6.0:
2424 + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
2425 +
2426 + lightningcss-android-arm64@1.32.0:
2427 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
2428 + engines: {node: '>= 12.0.0'}
2429 + cpu: [arm64]
2430 + os: [android]
2431 +
2432 + lightningcss-darwin-arm64@1.32.0:
2433 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
2434 + engines: {node: '>= 12.0.0'}
2435 + cpu: [arm64]
2436 + os: [darwin]
2437 +
2438 + lightningcss-darwin-x64@1.32.0:
2439 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
2440 + engines: {node: '>= 12.0.0'}
2441 + cpu: [x64]
2442 + os: [darwin]
2443 +
2444 + lightningcss-freebsd-x64@1.32.0:
2445 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
2446 + engines: {node: '>= 12.0.0'}
2447 + cpu: [x64]
2448 + os: [freebsd]
2449 +
2450 + lightningcss-linux-arm-gnueabihf@1.32.0:
2451 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
2452 + engines: {node: '>= 12.0.0'}
2453 + cpu: [arm]
2454 + os: [linux]
2455 +
2456 + lightningcss-linux-arm64-gnu@1.32.0:
2457 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
2458 + engines: {node: '>= 12.0.0'}
2459 + cpu: [arm64]
2460 + os: [linux]
2461 + libc: [glibc]
2462 +
2463 + lightningcss-linux-arm64-musl@1.32.0:
2464 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
2465 + engines: {node: '>= 12.0.0'}
2466 + cpu: [arm64]
2467 + os: [linux]
2468 + libc: [musl]
2469 +
2470 + lightningcss-linux-x64-gnu@1.32.0:
2471 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
2472 + engines: {node: '>= 12.0.0'}
2473 + cpu: [x64]
2474 + os: [linux]
2475 + libc: [glibc]
2476 +
2477 + lightningcss-linux-x64-musl@1.32.0:
2478 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
2479 + engines: {node: '>= 12.0.0'}
2480 + cpu: [x64]
2481 + os: [linux]
2482 + libc: [musl]
2483 +
2484 + lightningcss-win32-arm64-msvc@1.32.0:
2485 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
2486 + engines: {node: '>= 12.0.0'}
2487 + cpu: [arm64]
2488 + os: [win32]
2489 +
2490 + lightningcss-win32-x64-msvc@1.32.0:
2491 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
2492 + engines: {node: '>= 12.0.0'}
2493 + cpu: [x64]
2494 + os: [win32]
2495 +
2496 + lightningcss@1.32.0:
2497 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
2498 + engines: {node: '>= 12.0.0'}
2499 +
2500 + locate-path@6.0.0:
2501 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
2502 + engines: {node: '>=10'}
2503 +
2504 + lodash.merge@4.6.2:
2505 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
2506 +
2507 + loose-envify@1.4.0:
2508 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
2509 + hasBin: true
2510 +
2511 + loupe@3.2.1:
2512 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
2513 +
2514 + lru-cache@5.1.1:
2515 + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
2516 +
2517 + lucide-react@1.41.0:
2518 + resolution: {integrity: sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==}
2519 + peerDependencies:
2520 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
2521 +
2522 + magic-string@0.30.21:
2523 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
2524 +
2525 + math-intrinsics@1.1.0:
2526 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
2527 + engines: {node: '>= 0.4'}
2528 +
2529 + merge2@1.4.1:
2530 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
2531 + engines: {node: '>= 8'}
2532 +
2533 + micromatch@4.0.8:
2534 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
2535 + engines: {node: '>=8.6'}
2536 +
2537 + minimatch@10.2.6:
2538 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
2539 + engines: {node: 18 || 20 || >=22}
2540 +
2541 + minimatch@3.1.5:
2542 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
2543 +
2544 + minimist@1.2.8:
2545 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
2546 +
2547 + ms@2.1.3:
2548 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
2549 +
2550 + nanoid@3.3.18:
2551 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
2552 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
2553 + hasBin: true
2554 +
2555 + napi-postinstall@0.3.4:
2556 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
2557 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
2558 + hasBin: true
2559 +
2560 + natural-compare@1.4.0:
2561 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
2562 +
2563 + next-themes@0.4.6:
2564 + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
2565 + peerDependencies:
2566 + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
2567 + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
2568 +
2569 + next@16.3.4:
2570 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
2571 + engines: {node: '>=20.9.0'}
2572 + hasBin: true
2573 + peerDependencies:
2574 + '@opentelemetry/api': ^1.1.0
2575 + '@playwright/test': ^1.51.1
2576 + babel-plugin-react-compiler: '*'
2577 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
2578 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
2579 + sass: ^1.3.0
2580 + peerDependenciesMeta:
2581 + '@opentelemetry/api':
2582 + optional: true
2583 + '@playwright/test':
2584 + optional: true
2585 + babel-plugin-react-compiler:
2586 + optional: true
2587 + sass:
2588 + optional: true
2589 +
2590 + node-exports-info@1.6.2:
2591 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
2592 + engines: {node: '>= 0.4'}
2593 +
2594 + node-releases@2.0.54:
2595 + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
2596 + engines: {node: '>=18'}
2597 +
2598 + nth-check@2.1.1:
2599 + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
2600 +
2601 + object-assign@4.1.1:
2602 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
2603 + engines: {node: '>=0.10.0'}
2604 +
2605 + object-inspect@1.13.4:
2606 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
2607 + engines: {node: '>= 0.4'}
2608 +
2609 + object-keys@1.1.1:
2610 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
2611 + engines: {node: '>= 0.4'}
2612 +
2613 + object.assign@4.1.7:
2614 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
2615 + engines: {node: '>= 0.4'}
2616 +
2617 + object.entries@1.1.9:
2618 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
2619 + engines: {node: '>= 0.4'}
2620 +
2621 + object.fromentries@2.0.8:
2622 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
2623 + engines: {node: '>= 0.4'}
2624 +
2625 + object.groupby@1.0.3:
2626 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
2627 + engines: {node: '>= 0.4'}
2628 +
2629 + object.values@1.2.1:
2630 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
2631 + engines: {node: '>= 0.4'}
2632 +
2633 + on-exit-leak-free@2.1.2:
2634 + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
2635 + engines: {node: '>=14.0.0'}
2636 +
2637 + once@1.4.0:
2638 + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
2639 +
2640 + optionator@0.9.4:
2641 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
2642 + engines: {node: '>= 0.8.0'}
2643 +
2644 + own-keys@1.0.2:
2645 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
2646 + engines: {node: '>= 0.4'}
2647 +
2648 + p-limit@3.1.0:
2649 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
2650 + engines: {node: '>=10'}
2651 +
2652 + p-locate@5.0.0:
2653 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
2654 + engines: {node: '>=10'}
2655 +
2656 + parent-module@1.0.1:
2657 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
2658 + engines: {node: '>=6'}
2659 +
2660 + parse5-htmlparser2-tree-adapter@7.1.0:
2661 + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
2662 +
2663 + parse5-parser-stream@7.1.2:
2664 + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
2665 +
2666 + parse5@7.3.0:
2667 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
2668 +
2669 + path-exists@4.0.0:
2670 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
2671 + engines: {node: '>=8'}
2672 +
2673 + path-expression-matcher@1.6.2:
2674 + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
2675 + engines: {node: '>=14.0.0'}
2676 +
2677 + path-key@3.1.1:
2678 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
2679 + engines: {node: '>=8'}
2680 +
2681 + path-parse@1.0.7:
2682 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
2683 +
2684 + pathe@2.0.3:
2685 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
2686 +
2687 + pathval@2.0.1:
2688 + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
2689 + engines: {node: '>= 14.16'}
2690 +
2691 + pg-cloudflare@1.4.0:
2692 + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
2693 +
2694 + pg-connection-string@2.14.0:
2695 + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
2696 +
2697 + pg-int8@1.0.1:
2698 + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
2699 + engines: {node: '>=4.0.0'}
2700 +
2701 + pg-pool@3.14.0:
2702 + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
2703 + peerDependencies:
2704 + pg: '>=8.0'
2705 +
2706 + pg-protocol@1.16.0:
2707 + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
2708 +
2709 + pg-types@2.2.0:
2710 + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
2711 + engines: {node: '>=4'}
2712 +
2713 + pg@8.23.0:
2714 + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
2715 + engines: {node: '>= 16.0.0'}
2716 + peerDependencies:
2717 + pg-native: '>=3.0.1'
2718 + peerDependenciesMeta:
2719 + pg-native:
2720 + optional: true
2721 +
2722 + pgpass@1.0.5:
2723 + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
2724 +
2725 + picocolors@1.1.1:
2726 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
2727 +
2728 + picomatch@2.3.2:
2729 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
2730 + engines: {node: '>=8.6'}
2731 +
2732 + picomatch@4.0.7:
2733 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
2734 + engines: {node: '>=12'}
2735 +
2736 + pino-abstract-transport@2.0.0:
2737 + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
2738 +
2739 + pino-abstract-transport@3.0.0:
2740 + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
2741 +
2742 + pino-pretty@13.1.3:
2743 + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
2744 + hasBin: true
2745 +
2746 + pino-std-serializers@7.1.0:
2747 + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
2748 +
2749 + pino@9.14.0:
2750 + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
2751 + hasBin: true
2752 +
2753 + possible-typed-array-names@1.1.0:
2754 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
2755 + engines: {node: '>= 0.4'}
2756 +
2757 + postcss@8.5.23:
2758 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
2759 + engines: {node: ^10 || ^12 || >=14}
2760 +
2761 + postcss@8.5.28:
2762 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
2763 + engines: {node: ^10 || ^12 || >=14}
2764 +
2765 + postgres-array@2.0.0:
2766 + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
2767 + engines: {node: '>=4'}
2768 +
2769 + postgres-bytea@1.0.1:
2770 + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
2771 + engines: {node: '>=0.10.0'}
2772 +
2773 + postgres-date@1.0.7:
2774 + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
2775 + engines: {node: '>=0.10.0'}
2776 +
2777 + postgres-interval@1.2.0:
2778 + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
2779 + engines: {node: '>=0.10.0'}
2780 +
2781 + prelude-ls@1.2.1:
2782 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
2783 + engines: {node: '>= 0.8.0'}
2784 +
2785 + process-warning@4.0.1:
2786 + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
2787 +
2788 + process-warning@5.1.0:
2789 + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
2790 +
2791 + prom-client@15.1.3:
2792 + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
2793 + engines: {node: ^16 || ^18 || >=20}
2794 + deprecated: prom-client has been replaced by @prometheus-io/client
2795 +
2796 + prop-types@15.8.1:
2797 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2798 +
2799 + pump@3.0.4:
2800 + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
2801 +
2802 + punycode@2.3.1:
2803 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
2804 + engines: {node: '>=6'}
2805 +
2806 + queue-microtask@1.2.3:
2807 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2808 +
2809 + quick-format-unescaped@4.0.4:
2810 + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
2811 +
2812 + react-dom@19.2.8:
2813 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
2814 + peerDependencies:
2815 + react: ^19.2.8
2816 +
2817 + react-is@16.13.1:
2818 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
2819 +
2820 + react@19.2.8:
2821 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
2822 + engines: {node: '>=0.10.0'}
2823 +
2824 + readable-stream@3.6.2:
2825 + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
2826 + engines: {node: '>= 6'}
2827 +
2828 + real-require@0.2.0:
2829 + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
2830 + engines: {node: '>= 12.13.0'}
2831 +
2832 + redis-errors@1.2.0:
2833 + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
2834 + engines: {node: '>=4'}
2835 +
2836 + redis-parser@3.0.0:
2837 + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
2838 + engines: {node: '>=4'}
2839 +
2840 + reflect.getprototypeof@1.0.10:
2841 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
2842 + engines: {node: '>= 0.4'}
2843 +
2844 + regexp.prototype.flags@1.5.4:
2845 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
2846 + engines: {node: '>= 0.4'}
2847 +
2848 + require-from-string@2.0.2:
2849 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
2850 + engines: {node: '>=0.10.0'}
2851 +
2852 + resolve-from@4.0.0:
2853 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2854 + engines: {node: '>=4'}
2855 +
2856 + resolve-pkg-maps@1.0.0:
2857 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
2858 +
2859 + resolve@2.0.0-next.7:
2860 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
2861 + engines: {node: '>= 0.4'}
2862 + hasBin: true
2863 +
2864 + ret@0.5.0:
2865 + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
2866 + engines: {node: '>=10'}
2867 +
2868 + reusify@1.1.0:
2869 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
2870 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
2871 +
2872 + rfdc@1.4.1:
2873 + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
2874 +
2875 + rollup@4.63.1:
2876 + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
2877 + engines: {node: '>=18.0.0', npm: '>=8.0.0'}
2878 + hasBin: true
2879 +
2880 + run-parallel@1.2.0:
2881 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
2882 +
2883 + safe-array-concat@1.1.4:
2884 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
2885 + engines: {node: '>=0.4'}
2886 +
2887 + safe-buffer@5.2.1:
2888 + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
2889 +
2890 + safe-push-apply@1.0.0:
2891 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
2892 + engines: {node: '>= 0.4'}
2893 +
2894 + safe-regex-test@1.1.0:
2895 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
2896 + engines: {node: '>= 0.4'}
2897 +
2898 + safe-regex2@5.1.1:
2899 + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
2900 + hasBin: true
2901 +
2902 + safe-stable-stringify@2.5.0:
2903 + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
2904 + engines: {node: '>=10'}
2905 +
2906 + safer-buffer@2.1.2:
2907 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
2908 +
2909 + scheduler@0.27.0:
2910 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
2911 +
2912 + secure-json-parse@4.1.0:
2913 + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
2914 +
2915 + semver@6.3.1:
2916 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2917 + hasBin: true
2918 +
2919 + semver@7.8.5:
2920 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
2921 + engines: {node: '>=10'}
2922 + hasBin: true
2923 +
2924 + set-cookie-parser@2.7.2:
2925 + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
2926 +
2927 + set-function-length@1.2.2:
2928 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
2929 + engines: {node: '>= 0.4'}
2930 +
2931 + set-function-name@2.0.2:
2932 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
2933 + engines: {node: '>= 0.4'}
2934 +
2935 + set-proto@1.0.0:
2936 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
2937 + engines: {node: '>= 0.4'}
2938 +
2939 + sharp@0.35.4:
2940 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
2941 + engines: {node: '>=20.9.0'}
2942 + peerDependencies:
2943 + '@types/node': '*'
2944 + peerDependenciesMeta:
2945 + '@types/node':
2946 + optional: true
2947 +
2948 + shebang-command@2.0.0:
2949 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
2950 + engines: {node: '>=8'}
2951 +
2952 + shebang-regex@3.0.0:
2953 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
2954 + engines: {node: '>=8'}
2955 +
2956 + side-channel-list@1.0.1:
2957 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
2958 + engines: {node: '>= 0.4'}
2959 +
2960 + side-channel-map@1.0.1:
2961 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
2962 + engines: {node: '>= 0.4'}
2963 +
2964 + side-channel-weakmap@1.0.2:
2965 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
2966 + engines: {node: '>= 0.4'}
2967 +
2968 + side-channel@1.1.1:
2969 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
2970 + engines: {node: '>= 0.4'}
2971 +
2972 + siginfo@2.0.0:
2973 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
2974 +
2975 + sonic-boom@4.2.1:
2976 + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
2977 +
2978 + source-map-js@1.2.1:
2979 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
2980 + engines: {node: '>=0.10.0'}
2981 +
2982 + split2@4.2.0:
2983 + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
2984 + engines: {node: '>= 10.x'}
2985 +
2986 + stable-hash@0.0.5:
2987 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
2988 +
2989 + stackback@0.0.2:
2990 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
2991 +
2992 + standard-as-callback@2.1.0:
2993 + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
2994 +
2995 + standardwebhooks@1.1.1:
2996 + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
2997 +
2998 + std-env@3.10.0:
2999 + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
3000 +
3001 + stop-iteration-iterator@1.1.0:
3002 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
3003 + engines: {node: '>= 0.4'}
3004 +
3005 + stream-shift@1.0.3:
3006 + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
3007 +
3008 + string.prototype.includes@2.0.1:
3009 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
3010 + engines: {node: '>= 0.4'}
3011 +
3012 + string.prototype.matchall@4.1.0:
3013 + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
3014 + engines: {node: '>= 0.4'}
3015 +
3016 + string.prototype.repeat@1.0.0:
3017 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
3018 +
3019 + string.prototype.trim@1.2.11:
3020 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
3021 + engines: {node: '>= 0.4'}
3022 +
3023 + string.prototype.trimend@1.0.10:
3024 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
3025 + engines: {node: '>= 0.4'}
3026 +
3027 + string.prototype.trimstart@1.0.8:
3028 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
3029 + engines: {node: '>= 0.4'}
3030 +
3031 + string_decoder@1.3.0:
3032 + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
3033 +
3034 + strip-bom@3.0.0:
3035 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
3036 + engines: {node: '>=4'}
3037 +
3038 + strip-json-comments@3.1.1:
3039 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
3040 + engines: {node: '>=8'}
3041 +
3042 + strip-json-comments@5.0.3:
3043 + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
3044 + engines: {node: '>=14.16'}
3045 +
3046 + strip-literal@3.1.0:
3047 + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
3048 +
3049 + strnum@2.4.2:
3050 + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
3051 +
3052 + styled-jsx@5.1.6:
3053 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
3054 + engines: {node: '>= 12.0.0'}
3055 + peerDependencies:
3056 + '@babel/core': '*'
3057 + babel-plugin-macros: '*'
3058 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
3059 + peerDependenciesMeta:
3060 + '@babel/core':
3061 + optional: true
3062 + babel-plugin-macros:
3063 + optional: true
3064 +
3065 + supports-color@7.2.0:
3066 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
3067 + engines: {node: '>=8'}
3068 +
3069 + supports-preserve-symlinks-flag@1.0.0:
3070 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
3071 + engines: {node: '>= 0.4'}
3072 +
3073 + tailwindcss@4.3.3:
3074 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
3075 +
3076 + tapable@2.3.3:
3077 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
3078 + engines: {node: '>=6'}
3079 +
3080 + tdigest@0.1.3:
3081 + resolution: {integrity: sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==}
3082 +
3083 + thread-stream@3.2.0:
3084 + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==}
3085 +
3086 + tinybench@2.9.0:
3087 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
3088 +
3089 + tinyexec@0.3.2:
3090 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
3091 +
3092 + tinyglobby@0.2.17:
3093 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
3094 + engines: {node: '>=12.0.0'}
3095 +
3096 + tinypool@1.1.1:
3097 + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
3098 + engines: {node: ^18.0.0 || >=20.0.0}
3099 +
3100 + tinyrainbow@2.0.0:
3101 + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
3102 + engines: {node: '>=14.0.0'}
3103 +
3104 + tinyspy@4.0.6:
3105 + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
3106 + engines: {node: '>=14.0.0'}
3107 +
3108 + to-regex-range@5.0.1:
3109 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
3110 + engines: {node: '>=8.0'}
3111 +
3112 + toad-cache@3.7.4:
3113 + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
3114 + engines: {node: '>=20'}
3115 +
3116 + ts-algebra@2.0.0:
3117 + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
3118 +
3119 + ts-api-utils@2.5.0:
3120 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
3121 + engines: {node: '>=18.12'}
3122 + peerDependencies:
3123 + typescript: '>=4.8.4'
3124 +
3125 + tsconfig-paths@3.15.0:
3126 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
3127 +
3128 + tslib@2.8.1:
3129 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
3130 +
3131 + tsx@4.23.13:
3132 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
3133 + engines: {node: '>=18.0.0'}
3134 + hasBin: true
3135 +
3136 + type-check@0.4.0:
3137 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
3138 + engines: {node: '>= 0.8.0'}
3139 +
3140 + typed-array-buffer@1.0.3:
3141 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
3142 + engines: {node: '>= 0.4'}
3143 +
3144 + typed-array-byte-length@1.0.3:
3145 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
3146 + engines: {node: '>= 0.4'}
3147 +
3148 + typed-array-byte-offset@1.0.4:
3149 + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
3150 + engines: {node: '>= 0.4'}
3151 +
3152 + typed-array-length@1.0.8:
3153 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
3154 + engines: {node: '>= 0.4'}
3155 +
3156 + typescript-eslint@8.69.0:
3157 + resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==}
3158 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
3159 + peerDependencies:
3160 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
3161 + typescript: '>=4.8.4 <6.1.0'
3162 +
3163 + typescript@5.9.3:
3164 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
3165 + engines: {node: '>=14.17'}
3166 + hasBin: true
3167 +
3168 + unbox-primitive@1.1.0:
3169 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
3170 + engines: {node: '>= 0.4'}
3171 +
3172 + undici-types@7.18.2:
3173 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
3174 +
3175 + undici@7.29.1:
3176 + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
3177 + engines: {node: '>=20.18.1'}
3178 +
3179 + unrs-resolver@1.12.2:
3180 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
3181 +
3182 + update-browserslist-db@1.3.2:
3183 + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
3184 + hasBin: true
3185 + peerDependencies:
3186 + browserslist: '>= 4.21.0'
3187 +
3188 + uri-js@4.4.1:
3189 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
3190 +
3191 + util-deprecate@1.0.2:
3192 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
3193 +
3194 + vite-node@3.2.4:
3195 + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
3196 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
3197 + hasBin: true
3198 +
3199 + vite@7.3.6:
3200 + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
3201 + engines: {node: ^20.19.0 || >=22.12.0}
3202 + hasBin: true
3203 + peerDependencies:
3204 + '@types/node': ^20.19.0 || >=22.12.0
3205 + jiti: '>=1.21.0'
3206 + less: ^4.0.0
3207 + lightningcss: ^1.21.0
3208 + sass: ^1.70.0
3209 + sass-embedded: ^1.70.0
3210 + stylus: '>=0.54.8'
3211 + sugarss: ^5.0.0
3212 + terser: ^5.16.0
3213 + tsx: ^4.8.1
3214 + yaml: ^2.4.2
3215 + peerDependenciesMeta:
3216 + '@types/node':
3217 + optional: true
3218 + jiti:
3219 + optional: true
3220 + less:
3221 + optional: true
3222 + lightningcss:
3223 + optional: true
3224 + sass:
3225 + optional: true
3226 + sass-embedded:
3227 + optional: true
3228 + stylus:
3229 + optional: true
3230 + sugarss:
3231 + optional: true
3232 + terser:
3233 + optional: true
3234 + tsx:
3235 + optional: true
3236 + yaml:
3237 + optional: true
3238 +
3239 + vitest@3.2.7:
3240 + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
3241 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
3242 + hasBin: true
3243 + peerDependencies:
3244 + '@edge-runtime/vm': '*'
3245 + '@types/debug': ^4.1.12
3246 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
3247 + '@vitest/browser': 3.2.7
3248 + '@vitest/ui': 3.2.7
3249 + happy-dom: '*'
3250 + jsdom: '*'
3251 + peerDependenciesMeta:
3252 + '@edge-runtime/vm':
3253 + optional: true
3254 + '@types/debug':
3255 + optional: true
3256 + '@types/node':
3257 + optional: true
3258 + '@vitest/browser':
3259 + optional: true
3260 + '@vitest/ui':
3261 + optional: true
3262 + happy-dom:
3263 + optional: true
3264 + jsdom:
3265 + optional: true
3266 +
3267 + whatwg-encoding@3.1.1:
3268 + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
3269 + engines: {node: '>=18'}
3270 + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
3271 +
3272 + whatwg-mimetype@4.0.0:
3273 + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
3274 + engines: {node: '>=18'}
3275 +
3276 + which-boxed-primitive@1.1.1:
3277 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
3278 + engines: {node: '>= 0.4'}
3279 +
3280 + which-builtin-type@1.2.1:
3281 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
3282 + engines: {node: '>= 0.4'}
3283 +
3284 + which-collection@1.0.2:
3285 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
3286 + engines: {node: '>= 0.4'}
3287 +
3288 + which-typed-array@1.1.22:
3289 + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
3290 + engines: {node: '>= 0.4'}
3291 +
3292 + which@2.0.2:
3293 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
3294 + engines: {node: '>= 8'}
3295 + hasBin: true
3296 +
3297 + why-is-node-running@2.3.0:
3298 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
3299 + engines: {node: '>=8'}
3300 + hasBin: true
3301 +
3302 + word-wrap@1.2.5:
3303 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
3304 + engines: {node: '>=0.10.0'}
3305 +
3306 + wrappy@1.0.2:
3307 + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
3308 +
3309 + ws@8.21.3:
3310 + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
3311 + engines: {node: '>=10.0.0'}
3312 + peerDependencies:
3313 + bufferutil: ^4.0.1
3314 + utf-8-validate: '>=5.0.2'
3315 + peerDependenciesMeta:
3316 + bufferutil:
3317 + optional: true
3318 + utf-8-validate:
3319 + optional: true
3320 +
3321 + xml-naming@0.3.0:
3322 + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
3323 + engines: {node: '>=16.0.0'}
3324 +
3325 + xtend@4.0.2:
3326 + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
3327 + engines: {node: '>=0.4'}
3328 +
3329 + yallist@3.1.1:
3330 + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
3331 +
3332 + yaml@2.9.0:
3333 + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
3334 + engines: {node: '>= 14.6'}
3335 + hasBin: true
3336 +
3337 + yocto-queue@0.1.0:
3338 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
3339 + engines: {node: '>=10'}
3340 +
3341 + zod-validation-error@4.0.2:
3342 + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
3343 + engines: {node: '>=18.0.0'}
3344 + peerDependencies:
3345 + zod: ^3.25.0 || ^4.0.0
3346 +
3347 + zod@4.5.4:
3348 + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
3349 +
3350 +snapshots:
3351 +
3352 + '@alloc/quick-lru@5.3.0': {}
3353 +
3354 + '@anthropic-ai/sdk@0.124.0(zod@4.5.4)':
3355 + dependencies:
3356 + json-schema-to-ts: 3.1.1
3357 + standardwebhooks: 1.1.1
3358 + optionalDependencies:
3359 + zod: 4.5.4
3360 +
3361 + '@babel/code-frame@7.29.7':
3362 + dependencies:
3363 + '@babel/helper-validator-identifier': 7.29.7
3364 + js-tokens: 4.0.0
3365 + picocolors: 1.1.1
3366 +
3367 + '@babel/compat-data@7.29.7': {}
3368 +
3369 + '@babel/core@7.29.7':
3370 + dependencies:
3371 + '@babel/code-frame': 7.29.7
3372 + '@babel/generator': 7.29.8
3373 + '@babel/helper-compilation-targets': 7.29.7
3374 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
3375 + '@babel/helpers': 7.29.7
3376 + '@babel/parser': 7.29.8
3377 + '@babel/template': 7.29.7
3378 + '@babel/traverse': 7.29.8
3379 + '@babel/types': 7.29.8
3380 + '@jridgewell/remapping': 2.3.5
3381 + convert-source-map: 2.0.0
3382 + debug: 4.4.3
3383 + gensync: 1.0.0-beta.2
3384 + json5: 2.2.3
3385 + semver: 6.3.1
3386 + transitivePeerDependencies:
3387 + - supports-color
3388 +
3389 + '@babel/generator@7.29.8':
3390 + dependencies:
3391 + '@babel/parser': 7.29.8
3392 + '@babel/types': 7.29.8
3393 + '@jridgewell/gen-mapping': 0.3.13
3394 + '@jridgewell/trace-mapping': 0.3.31
3395 + jsesc: 3.1.0
3396 +
3397 + '@babel/helper-compilation-targets@7.29.7':
3398 + dependencies:
3399 + '@babel/compat-data': 7.29.7
3400 + '@babel/helper-validator-option': 7.29.7
3401 + browserslist: 4.28.9
3402 + lru-cache: 5.1.1
3403 + semver: 6.3.1
3404 +
3405 + '@babel/helper-globals@7.29.7': {}
3406 +
3407 + '@babel/helper-module-imports@7.29.7':
3408 + dependencies:
3409 + '@babel/traverse': 7.29.8
3410 + '@babel/types': 7.29.8
3411 + transitivePeerDependencies:
3412 + - supports-color
3413 +
3414 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
3415 + dependencies:
3416 + '@babel/core': 7.29.7
3417 + '@babel/helper-module-imports': 7.29.7
3418 + '@babel/helper-validator-identifier': 7.29.7
3419 + '@babel/traverse': 7.29.8
3420 + transitivePeerDependencies:
3421 + - supports-color
3422 +
3423 + '@babel/helper-string-parser@7.29.7': {}
3424 +
3425 + '@babel/helper-validator-identifier@7.29.7': {}
3426 +
3427 + '@babel/helper-validator-option@7.29.7': {}
3428 +
3429 + '@babel/helpers@7.29.7':
3430 + dependencies:
3431 + '@babel/template': 7.29.7
3432 + '@babel/types': 7.29.8
3433 +
3434 + '@babel/parser@7.29.8':
3435 + dependencies:
3436 + '@babel/types': 7.29.8
3437 +
3438 + '@babel/runtime@7.29.7': {}
3439 +
3440 + '@babel/template@7.29.7':
3441 + dependencies:
3442 + '@babel/code-frame': 7.29.7
3443 + '@babel/parser': 7.29.8
3444 + '@babel/types': 7.29.8
3445 +
3446 + '@babel/traverse@7.29.8':
3447 + dependencies:
3448 + '@babel/code-frame': 7.29.7
3449 + '@babel/generator': 7.29.8
3450 + '@babel/helper-globals': 7.29.7
3451 + '@babel/parser': 7.29.8
3452 + '@babel/template': 7.29.7
3453 + '@babel/types': 7.29.8
3454 + debug: 4.4.3
3455 + transitivePeerDependencies:
3456 + - supports-color
3457 +
3458 + '@babel/types@7.29.8':
3459 + dependencies:
3460 + '@babel/helper-string-parser': 7.29.7
3461 + '@babel/helper-validator-identifier': 7.29.7
3462 +
3463 + '@emnapi/core@1.10.0':
3464 + dependencies:
3465 + '@emnapi/wasi-threads': 1.2.1
3466 + tslib: 2.8.1
3467 + optional: true
3468 +
3469 + '@emnapi/runtime@1.10.0':
3470 + dependencies:
3471 + tslib: 2.8.1
3472 + optional: true
3473 +
3474 + '@emnapi/runtime@1.11.3':
3475 + dependencies:
3476 + tslib: 2.8.1
3477 + optional: true
3478 +
3479 + '@emnapi/wasi-threads@1.2.1':
3480 + dependencies:
3481 + tslib: 2.8.1
3482 + optional: true
3483 +
3484 + '@esbuild/aix-ppc64@0.28.2':
3485 + optional: true
3486 +
3487 + '@esbuild/android-arm64@0.28.2':
3488 + optional: true
3489 +
3490 + '@esbuild/android-arm@0.28.2':
3491 + optional: true
3492 +
3493 + '@esbuild/android-x64@0.28.2':
3494 + optional: true
3495 +
3496 + '@esbuild/darwin-arm64@0.28.2':
3497 + optional: true
3498 +
3499 + '@esbuild/darwin-x64@0.28.2':
3500 + optional: true
3501 +
3502 + '@esbuild/freebsd-arm64@0.28.2':
3503 + optional: true
3504 +
3505 + '@esbuild/freebsd-x64@0.28.2':
3506 + optional: true
3507 +
3508 + '@esbuild/linux-arm64@0.28.2':
3509 + optional: true
3510 +
3511 + '@esbuild/linux-arm@0.28.2':
3512 + optional: true
3513 +
3514 + '@esbuild/linux-ia32@0.28.2':
3515 + optional: true
3516 +
3517 + '@esbuild/linux-loong64@0.28.2':
3518 + optional: true
3519 +
3520 + '@esbuild/linux-mips64el@0.28.2':
3521 + optional: true
3522 +
3523 + '@esbuild/linux-ppc64@0.28.2':
3524 + optional: true
3525 +
3526 + '@esbuild/linux-riscv64@0.28.2':
3527 + optional: true
3528 +
3529 + '@esbuild/linux-s390x@0.28.2':
3530 + optional: true
3531 +
3532 + '@esbuild/linux-x64@0.28.2':
3533 + optional: true
3534 +
3535 + '@esbuild/netbsd-arm64@0.28.2':
3536 + optional: true
3537 +
3538 + '@esbuild/netbsd-x64@0.28.2':
3539 + optional: true
3540 +
3541 + '@esbuild/openbsd-arm64@0.28.2':
3542 + optional: true
3543 +
3544 + '@esbuild/openbsd-x64@0.28.2':
3545 + optional: true
3546 +
3547 + '@esbuild/openharmony-arm64@0.28.2':
3548 + optional: true
3549 +
3550 + '@esbuild/sunos-x64@0.28.2':
3551 + optional: true
3552 +
3553 + '@esbuild/win32-arm64@0.28.2':
3554 + optional: true
3555 +
3556 + '@esbuild/win32-ia32@0.28.2':
3557 + optional: true
3558 +
3559 + '@esbuild/win32-x64@0.28.2':
3560 + optional: true
3561 +
3562 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
3563 + dependencies:
3564 + eslint: 9.39.5(jiti@2.7.0)
3565 + eslint-visitor-keys: 3.4.3
3566 +
3567 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
3568 + dependencies:
3569 + eslint: 9.39.5(jiti@2.7.0)
3570 + eslint-visitor-keys: 3.4.3
3571 +
3572 + '@eslint-community/regexpp@4.12.2': {}
3573 +
3574 + '@eslint/config-array@0.21.2':
3575 + dependencies:
3576 + '@eslint/object-schema': 2.1.7
3577 + debug: 4.4.3
3578 + minimatch: 3.1.5
3579 + transitivePeerDependencies:
3580 + - supports-color
3581 +
3582 + '@eslint/config-helpers@0.4.2':
3583 + dependencies:
3584 + '@eslint/core': 0.17.0
3585 +
3586 + '@eslint/core@0.17.0':
3587 + dependencies:
3588 + '@types/json-schema': 7.0.15
3589 +
3590 + '@eslint/eslintrc@3.3.7':
3591 + dependencies:
3592 + ajv: 6.15.0
3593 + debug: 4.4.3
3594 + espree: 10.4.0
3595 + globals: 14.0.0
3596 + ignore: 5.3.2
3597 + import-fresh: 3.3.1
3598 + js-yaml: 4.3.2
3599 + minimatch: 3.1.5
3600 + strip-json-comments: 3.1.1
3601 + transitivePeerDependencies:
3602 + - supports-color
3603 +
3604 + '@eslint/js@9.39.5': {}
3605 +
3606 + '@eslint/object-schema@2.1.7': {}
3607 +
3608 + '@eslint/plugin-kit@0.4.1':
3609 + dependencies:
3610 + '@eslint/core': 0.17.0
3611 + levn: 0.4.1
3612 +
3613 + '@fastify/ajv-compiler@4.0.6':
3614 + dependencies:
3615 + ajv: 8.20.0
3616 + ajv-formats: 3.0.1(ajv@8.20.0)
3617 + fast-uri: 4.1.4
3618 +
3619 + '@fastify/cors@11.3.0':
3620 + dependencies:
3621 + fastify-plugin: 6.0.0
3622 + toad-cache: 3.7.4
3623 +
3624 + '@fastify/error@4.2.0': {}
3625 +
3626 + '@fastify/fast-json-stringify-compiler@5.1.0':
3627 + dependencies:
3628 + fast-json-stringify: 7.0.1
3629 +
3630 + '@fastify/forwarded@3.0.2': {}
3631 +
3632 + '@fastify/merge-json-schemas@0.2.1':
3633 + dependencies:
3634 + dequal: 2.0.3
3635 +
3636 + '@fastify/proxy-addr@5.1.0':
3637 + dependencies:
3638 + '@fastify/forwarded': 3.0.2
3639 + ipaddr.js: 2.5.0
3640 +
3641 + '@fastify/rate-limit@10.3.0':
3642 + dependencies:
3643 + '@lukeed/ms': 2.0.2
3644 + fastify-plugin: 5.1.0
3645 + toad-cache: 3.7.4
3646 +
3647 + '@fastify/reply-from@12.6.5':
3648 + dependencies:
3649 + '@fastify/error': 4.2.0
3650 + end-of-stream: 1.4.5
3651 + fast-content-type-parse: 3.0.0
3652 + fast-querystring: 1.1.2
3653 + fastify-plugin: 6.0.0
3654 + toad-cache: 3.7.4
3655 + undici: 7.29.1
3656 +
3657 + '@fastify/websocket@11.3.0':
3658 + dependencies:
3659 + duplexify: 4.1.3
3660 + fastify-plugin: 6.0.0
3661 + ws: 8.21.3
3662 + transitivePeerDependencies:
3663 + - bufferutil
3664 + - utf-8-validate
3665 +
3666 + '@humanfs/core@0.19.2':
3667 + dependencies:
3668 + '@humanfs/types': 0.15.0
3669 +
3670 + '@humanfs/node@0.16.8':
3671 + dependencies:
3672 + '@humanfs/core': 0.19.2
3673 + '@humanfs/types': 0.15.0
3674 + '@humanwhocodes/retry': 0.4.3
3675 +
3676 + '@humanfs/types@0.15.0': {}
3677 +
3678 + '@humanwhocodes/module-importer@1.0.1': {}
3679 +
3680 + '@humanwhocodes/retry@0.4.3': {}
3681 +
3682 + '@img/colour@1.1.0':
3683 + optional: true
3684 +
3685 + '@img/sharp-darwin-arm64@0.35.4':
3686 + optionalDependencies:
3687 + '@img/sharp-libvips-darwin-arm64': 1.3.3
3688 + optional: true
3689 +
3690 + '@img/sharp-darwin-x64@0.35.4':
3691 + optionalDependencies:
3692 + '@img/sharp-libvips-darwin-x64': 1.3.3
3693 + optional: true
3694 +
3695 + '@img/sharp-freebsd-wasm32@0.35.4':
3696 + dependencies:
3697 + '@img/sharp-wasm32': 0.35.4
3698 + optional: true
3699 +
3700 + '@img/sharp-libvips-darwin-arm64@1.3.3':
3701 + optional: true
3702 +
3703 + '@img/sharp-libvips-darwin-x64@1.3.3':
3704 + optional: true
3705 +
3706 + '@img/sharp-libvips-linux-arm64@1.3.3':
3707 + optional: true
3708 +
3709 + '@img/sharp-libvips-linux-arm@1.3.3':
3710 + optional: true
3711 +
3712 + '@img/sharp-libvips-linux-ppc64@1.3.3':
3713 + optional: true
3714 +
3715 + '@img/sharp-libvips-linux-riscv64@1.3.3':
3716 + optional: true
3717 +
3718 + '@img/sharp-libvips-linux-s390x@1.3.3':
3719 + optional: true
3720 +
3721 + '@img/sharp-libvips-linux-x64@1.3.3':
3722 + optional: true
3723 +
3724 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
3725 + optional: true
3726 +
3727 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
3728 + optional: true
3729 +
3730 + '@img/sharp-linux-arm64@0.35.4':
3731 + optionalDependencies:
3732 + '@img/sharp-libvips-linux-arm64': 1.3.3
3733 + optional: true
3734 +
3735 + '@img/sharp-linux-arm@0.35.4':
3736 + optionalDependencies:
3737 + '@img/sharp-libvips-linux-arm': 1.3.3
3738 + optional: true
3739 +
3740 + '@img/sharp-linux-ppc64@0.35.4':
3741 + optionalDependencies:
3742 + '@img/sharp-libvips-linux-ppc64': 1.3.3
3743 + optional: true
3744 +
3745 + '@img/sharp-linux-riscv64@0.35.4':
3746 + optionalDependencies:
3747 + '@img/sharp-libvips-linux-riscv64': 1.3.3
3748 + optional: true
3749 +
3750 + '@img/sharp-linux-s390x@0.35.4':
3751 + optionalDependencies:
3752 + '@img/sharp-libvips-linux-s390x': 1.3.3
3753 + optional: true
3754 +
3755 + '@img/sharp-linux-x64@0.35.4':
3756 + optionalDependencies:
3757 + '@img/sharp-libvips-linux-x64': 1.3.3
3758 + optional: true
3759 +
3760 + '@img/sharp-linuxmusl-arm64@0.35.4':
3761 + optionalDependencies:
3762 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
3763 + optional: true
3764 +
3765 + '@img/sharp-linuxmusl-x64@0.35.4':
3766 + optionalDependencies:
3767 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
3768 + optional: true
3769 +
3770 + '@img/sharp-wasm32@0.35.4':
3771 + dependencies:
3772 + '@emnapi/runtime': 1.11.3
3773 + optional: true
3774 +
3775 + '@img/sharp-webcontainers-wasm32@0.35.4':
3776 + dependencies:
3777 + '@img/sharp-wasm32': 0.35.4
3778 + optional: true
3779 +
3780 + '@img/sharp-win32-arm64@0.35.4':
3781 + optional: true
3782 +
3783 + '@img/sharp-win32-ia32@0.35.4':
3784 + optional: true
3785 +
3786 + '@img/sharp-win32-x64@0.35.4':
3787 + optional: true
3788 +
3789 + '@ioredis/commands@1.10.0': {}
3790 +
3791 + '@jridgewell/gen-mapping@0.3.13':
3792 + dependencies:
3793 + '@jridgewell/sourcemap-codec': 1.6.0
3794 + '@jridgewell/trace-mapping': 0.3.31
3795 +
3796 + '@jridgewell/remapping@2.3.5':
3797 + dependencies:
3798 + '@jridgewell/gen-mapping': 0.3.13
3799 + '@jridgewell/trace-mapping': 0.3.31
3800 +
3801 + '@jridgewell/resolve-uri@3.1.2': {}
3802 +
3803 + '@jridgewell/sourcemap-codec@1.6.0': {}
3804 +
3805 + '@jridgewell/trace-mapping@0.3.31':
3806 + dependencies:
3807 + '@jridgewell/resolve-uri': 3.1.2
3808 + '@jridgewell/sourcemap-codec': 1.6.0
3809 +
3810 + '@lukeed/ms@2.0.2': {}
3811 +
3812 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
3813 + optional: true
3814 +
3815 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
3816 + dependencies:
3817 + '@emnapi/core': 1.10.0
3818 + '@emnapi/runtime': 1.10.0
3819 + '@tybys/wasm-util': 0.10.3
3820 + optional: true
3821 +
3822 + '@next/env@16.3.4': {}
3823 +
3824 + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))':
3825 + dependencies:
3826 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
3827 + fast-glob: 3.3.1
3828 + transitivePeerDependencies:
3829 + - eslint
3830 +
3831 + '@next/swc-darwin-arm64@16.3.4':
3832 + optional: true
3833 +
3834 + '@next/swc-darwin-x64@16.3.4':
3835 + optional: true
3836 +
3837 + '@next/swc-linux-arm64-gnu@16.3.4':
3838 + optional: true
3839 +
3840 + '@next/swc-linux-arm64-musl@16.3.4':
3841 + optional: true
3842 +
3843 + '@next/swc-linux-x64-gnu@16.3.4':
3844 + optional: true
3845 +
3846 + '@next/swc-linux-x64-musl@16.3.4':
3847 + optional: true
3848 +
3849 + '@next/swc-win32-arm64-msvc@16.3.4':
3850 + optional: true
3851 +
3852 + '@next/swc-win32-x64-msvc@16.3.4':
3853 + optional: true
3854 +
3855 + '@nodable/entities@3.0.0': {}
3856 +
3857 + '@nodelib/fs.scandir@2.1.5':
3858 + dependencies:
3859 + '@nodelib/fs.stat': 2.0.5
3860 + run-parallel: 1.2.0
3861 +
3862 + '@nodelib/fs.stat@2.0.5': {}
3863 +
3864 + '@nodelib/fs.walk@1.2.8':
3865 + dependencies:
3866 + '@nodelib/fs.scandir': 2.1.5
3867 + fastq: 1.20.3
3868 +
3869 + '@nolyfill/is-core-module@1.0.39': {}
3870 +
3871 + '@opentelemetry/api@1.9.1': {}
3872 +
3873 + '@pinojs/redact@0.4.0': {}
3874 +
3875 + '@rollup/rollup-android-arm-eabi@4.63.1':
3876 + optional: true
3877 +
3878 + '@rollup/rollup-android-arm64@4.63.1':
3879 + optional: true
3880 +
3881 + '@rollup/rollup-darwin-arm64@4.63.1':
3882 + optional: true
3883 +
3884 + '@rollup/rollup-darwin-x64@4.63.1':
3885 + optional: true
3886 +
3887 + '@rollup/rollup-freebsd-arm64@4.63.1':
3888 + optional: true
3889 +
3890 + '@rollup/rollup-freebsd-x64@4.63.1':
3891 + optional: true
3892 +
3893 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
3894 + optional: true
3895 +
3896 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
3897 + optional: true
3898 +
3899 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
3900 + optional: true
3901 +
3902 + '@rollup/rollup-linux-arm64-musl@4.63.1':
3903 + optional: true
3904 +
3905 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
3906 + optional: true
3907 +
3908 + '@rollup/rollup-linux-loong64-musl@4.63.1':
3909 + optional: true
3910 +
3911 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
3912 + optional: true
3913 +
3914 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
3915 + optional: true
3916 +
3917 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
3918 + optional: true
3919 +
3920 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
3921 + optional: true
3922 +
3923 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
3924 + optional: true
3925 +
3926 + '@rollup/rollup-linux-x64-gnu@4.63.1':
3927 + optional: true
3928 +
3929 + '@rollup/rollup-linux-x64-musl@4.63.1':
3930 + optional: true
3931 +
3932 + '@rollup/rollup-openbsd-x64@4.63.1':
3933 + optional: true
3934 +
3935 + '@rollup/rollup-openharmony-arm64@4.63.1':
3936 + optional: true
3937 +
3938 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
3939 + optional: true
3940 +
3941 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
3942 + optional: true
3943 +
3944 + '@rollup/rollup-win32-x64-gnu@4.63.1':
3945 + optional: true
3946 +
3947 + '@rollup/rollup-win32-x64-msvc@4.63.1':
3948 + optional: true
3949 +
3950 + '@rtsao/scc@1.1.0': {}
3951 +
3952 + '@stablelib/base64@1.0.1': {}
3953 +
3954 + '@swc/helpers@0.5.23':
3955 + dependencies:
3956 + tslib: 2.8.1
3957 +
3958 + '@tailwindcss/node@4.3.3':
3959 + dependencies:
3960 + '@jridgewell/remapping': 2.3.5
3961 + enhanced-resolve: 5.24.5
3962 + jiti: 2.7.0
3963 + lightningcss: 1.32.0
3964 + magic-string: 0.30.21
3965 + source-map-js: 1.2.1
3966 + tailwindcss: 4.3.3
3967 +
3968 + '@tailwindcss/oxide-android-arm64@4.3.3':
3969 + optional: true
3970 +
3971 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
3972 + optional: true
3973 +
3974 + '@tailwindcss/oxide-darwin-x64@4.3.3':
3975 + optional: true
3976 +
3977 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
3978 + optional: true
3979 +
3980 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
3981 + optional: true
3982 +
3983 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
3984 + optional: true
3985 +
3986 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
3987 + optional: true
3988 +
3989 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
3990 + optional: true
3991 +
3992 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
3993 + optional: true
3994 +
3995 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
3996 + optional: true
3997 +
3998 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
3999 + optional: true
4000 +
4001 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
4002 + optional: true
4003 +
4004 + '@tailwindcss/oxide@4.3.3':
4005 + optionalDependencies:
4006 + '@tailwindcss/oxide-android-arm64': 4.3.3
4007 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
4008 + '@tailwindcss/oxide-darwin-x64': 4.3.3
4009 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
4010 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
4011 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
4012 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
4013 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
4014 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
4015 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
4016 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
4017 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
4018 +
4019 + '@tailwindcss/postcss@4.3.3':
4020 + dependencies:
4021 + '@alloc/quick-lru': 5.3.0
4022 + '@tailwindcss/node': 4.3.3
4023 + '@tailwindcss/oxide': 4.3.3
4024 + postcss: 8.5.28
4025 + tailwindcss: 4.3.3
4026 +
4027 + '@tybys/wasm-util@0.10.3':
4028 + dependencies:
4029 + tslib: 2.8.1
4030 + optional: true
4031 +
4032 + '@types/chai@5.2.3':
4033 + dependencies:
4034 + '@types/deep-eql': 4.0.2
4035 + assertion-error: 2.0.1
4036 +
4037 + '@types/deep-eql@4.0.2': {}
4038 +
4039 + '@types/estree@1.0.9': {}
4040 +
4041 + '@types/json-schema@7.0.15': {}
4042 +
4043 + '@types/json5@0.0.29': {}
4044 +
4045 + '@types/node@24.13.3':
4046 + dependencies:
4047 + undici-types: 7.18.2
4048 +
4049 + '@types/pg@8.23.1':
4050 + dependencies:
4051 + '@types/node': 24.13.3
4052 + pg-protocol: 1.16.0
4053 + pg-types: 2.2.0
4054 +
4055 + '@types/react-dom@19.2.7(@types/react@19.2.18)':
4056 + dependencies:
4057 + '@types/react': 19.2.18
4058 +
4059 + '@types/react@19.2.18':
4060 + dependencies:
4061 + csstype: 3.2.3
4062 +
4063 + '@types/ws@8.18.1':
4064 + dependencies:
4065 + '@types/node': 24.13.3
4066 +
4067 + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
4068 + dependencies:
4069 + '@eslint-community/regexpp': 4.12.2
4070 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4071 + '@typescript-eslint/scope-manager': 8.69.0
4072 + '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4073 + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4074 + '@typescript-eslint/visitor-keys': 8.69.0
4075 + eslint: 9.39.5(jiti@2.7.0)
4076 + ignore: 7.0.8
4077 + natural-compare: 1.4.0
4078 + ts-api-utils: 2.5.0(typescript@5.9.3)
4079 + typescript: 5.9.3
4080 + transitivePeerDependencies:
4081 + - supports-color
4082 +
4083 + '@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
4084 + dependencies:
4085 + '@typescript-eslint/scope-manager': 8.69.0
4086 + '@typescript-eslint/types': 8.69.0
4087 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.9.3)
4088 + '@typescript-eslint/visitor-keys': 8.69.0
4089 + debug: 4.4.3
4090 + eslint: 9.39.5(jiti@2.7.0)
4091 + typescript: 5.9.3
4092 + transitivePeerDependencies:
4093 + - supports-color
4094 +
4095 + '@typescript-eslint/project-service@8.69.0(typescript@5.9.3)':
4096 + dependencies:
4097 + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.9.3)
4098 + '@typescript-eslint/types': 8.69.0
4099 + debug: 4.4.3
4100 + typescript: 5.9.3
4101 + transitivePeerDependencies:
4102 + - supports-color
4103 +
4104 + '@typescript-eslint/scope-manager@8.69.0':
4105 + dependencies:
4106 + '@typescript-eslint/types': 8.69.0
4107 + '@typescript-eslint/visitor-keys': 8.69.0
4108 +
4109 + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@5.9.3)':
4110 + dependencies:
4111 + typescript: 5.9.3
4112 +
4113 + '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
4114 + dependencies:
4115 + '@typescript-eslint/types': 8.69.0
4116 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.9.3)
4117 + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4118 + debug: 4.4.3
4119 + eslint: 9.39.5(jiti@2.7.0)
4120 + ts-api-utils: 2.5.0(typescript@5.9.3)
4121 + typescript: 5.9.3
4122 + transitivePeerDependencies:
4123 + - supports-color
4124 +
4125 + '@typescript-eslint/types@8.69.0': {}
4126 +
4127 + '@typescript-eslint/typescript-estree@8.69.0(typescript@5.9.3)':
4128 + dependencies:
4129 + '@typescript-eslint/project-service': 8.69.0(typescript@5.9.3)
4130 + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.9.3)
4131 + '@typescript-eslint/types': 8.69.0
4132 + '@typescript-eslint/visitor-keys': 8.69.0
4133 + debug: 4.4.3
4134 + minimatch: 10.2.6
4135 + semver: 7.8.5
4136 + tinyglobby: 0.2.17
4137 + ts-api-utils: 2.5.0(typescript@5.9.3)
4138 + typescript: 5.9.3
4139 + transitivePeerDependencies:
4140 + - supports-color
4141 +
4142 + '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
4143 + dependencies:
4144 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
4145 + '@typescript-eslint/scope-manager': 8.69.0
4146 + '@typescript-eslint/types': 8.69.0
4147 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.9.3)
4148 + eslint: 9.39.5(jiti@2.7.0)
4149 + typescript: 5.9.3
4150 + transitivePeerDependencies:
4151 + - supports-color
4152 +
4153 + '@typescript-eslint/visitor-keys@8.69.0':
4154 + dependencies:
4155 + '@typescript-eslint/types': 8.69.0
4156 + eslint-visitor-keys: 5.0.1
4157 +
4158 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
4159 + optional: true
4160 +
4161 + '@unrs/resolver-binding-android-arm64@1.12.2':
4162 + optional: true
4163 +
4164 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
4165 + optional: true
4166 +
4167 + '@unrs/resolver-binding-darwin-x64@1.12.2':
4168 + optional: true
4169 +
4170 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
4171 + optional: true
4172 +
4173 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
4174 + optional: true
4175 +
4176 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
4177 + optional: true
4178 +
4179 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
4180 + optional: true
4181 +
4182 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
4183 + optional: true
4184 +
4185 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
4186 + optional: true
4187 +
4188 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
4189 + optional: true
4190 +
4191 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
4192 + optional: true
4193 +
4194 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
4195 + optional: true
4196 +
4197 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
4198 + optional: true
4199 +
4200 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
4201 + optional: true
4202 +
4203 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
4204 + optional: true
4205 +
4206 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
4207 + optional: true
4208 +
4209 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
4210 + optional: true
4211 +
4212 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
4213 + dependencies:
4214 + '@emnapi/core': 1.10.0
4215 + '@emnapi/runtime': 1.10.0
4216 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
4217 + optional: true
4218 +
4219 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
4220 + optional: true
4221 +
4222 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
4223 + optional: true
4224 +
4225 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
4226 + optional: true
4227 +
4228 + '@vitest/expect@3.2.7':
4229 + dependencies:
4230 + '@types/chai': 5.2.3
4231 + '@vitest/spy': 3.2.7
4232 + '@vitest/utils': 3.2.7
4233 + chai: 5.3.3
4234 + tinyrainbow: 2.0.0
4235 +
4236 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0))':
4237 + dependencies:
4238 + '@vitest/spy': 3.2.7
4239 + estree-walker: 3.0.3
4240 + magic-string: 0.30.21
4241 + optionalDependencies:
4242 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
4243 +
4244 + '@vitest/pretty-format@3.2.7':
4245 + dependencies:
4246 + tinyrainbow: 2.0.0
4247 +
4248 + '@vitest/runner@3.2.7':
4249 + dependencies:
4250 + '@vitest/utils': 3.2.7
4251 + pathe: 2.0.3
4252 + strip-literal: 3.1.0
4253 +
4254 + '@vitest/snapshot@3.2.7':
4255 + dependencies:
4256 + '@vitest/pretty-format': 3.2.7
4257 + magic-string: 0.30.21
4258 + pathe: 2.0.3
4259 +
4260 + '@vitest/spy@3.2.7':
4261 + dependencies:
4262 + tinyspy: 4.0.6
4263 +
4264 + '@vitest/utils@3.2.7':
4265 + dependencies:
4266 + '@vitest/pretty-format': 3.2.7
4267 + loupe: 3.2.1
4268 + tinyrainbow: 2.0.0
4269 +
4270 + abstract-logging@2.0.1: {}
4271 +
4272 + acorn-jsx@5.3.2(acorn@8.18.0):
4273 + dependencies:
4274 + acorn: 8.18.0
4275 +
4276 + acorn@8.18.0: {}
4277 +
4278 + ajv-formats@3.0.1(ajv@8.20.0):
4279 + optionalDependencies:
4280 + ajv: 8.20.0
4281 +
4282 + ajv@6.15.0:
4283 + dependencies:
4284 + fast-deep-equal: 3.1.3
4285 + fast-json-stable-stringify: 2.1.0
4286 + json-schema-traverse: 0.4.1
4287 + uri-js: 4.4.1
4288 +
4289 + ajv@8.20.0:
4290 + dependencies:
4291 + fast-deep-equal: 3.1.3
4292 + fast-uri: 3.1.7
4293 + json-schema-traverse: 1.0.0
4294 + require-from-string: 2.0.2
4295 +
4296 + ansi-styles@4.3.0:
4297 + dependencies:
4298 + color-convert: 2.0.1
4299 +
4300 + anynum@1.0.1: {}
4301 +
4302 + argparse@2.0.1: {}
4303 +
4304 + aria-query@5.3.2: {}
4305 +
4306 + array-buffer-byte-length@1.0.2:
4307 + dependencies:
4308 + call-bound: 1.0.4
4309 + is-array-buffer: 3.0.5
4310 +
4311 + array-includes@3.1.9:
4312 + dependencies:
4313 + call-bind: 1.0.9
4314 + call-bound: 1.0.4
4315 + define-properties: 1.2.1
4316 + es-abstract: 1.24.2
4317 + es-object-atoms: 1.1.2
4318 + get-intrinsic: 1.3.0
4319 + is-string: 1.1.1
4320 + math-intrinsics: 1.1.0
4321 +
4322 + array.prototype.findlast@1.2.5:
4323 + dependencies:
4324 + call-bind: 1.0.9
4325 + define-properties: 1.2.1
4326 + es-abstract: 1.24.2
4327 + es-errors: 1.3.0
4328 + es-object-atoms: 1.1.2
4329 + es-shim-unscopables: 1.1.0
4330 +
4331 + array.prototype.findlastindex@1.2.6:
4332 + dependencies:
4333 + call-bind: 1.0.9
4334 + call-bound: 1.0.4
4335 + define-properties: 1.2.1
4336 + es-abstract: 1.24.2
4337 + es-errors: 1.3.0
4338 + es-object-atoms: 1.1.2
4339 + es-shim-unscopables: 1.1.0
4340 +
4341 + array.prototype.flat@1.3.3:
4342 + dependencies:
4343 + call-bind: 1.0.9
4344 + define-properties: 1.2.1
4345 + es-abstract: 1.24.2
4346 + es-shim-unscopables: 1.1.0
4347 +
4348 + array.prototype.flatmap@1.3.3:
4349 + dependencies:
4350 + call-bind: 1.0.9
4351 + define-properties: 1.2.1
4352 + es-abstract: 1.24.2
4353 + es-shim-unscopables: 1.1.0
4354 +
4355 + array.prototype.tosorted@1.1.4:
4356 + dependencies:
4357 + call-bind: 1.0.9
4358 + define-properties: 1.2.1
4359 + es-abstract: 1.24.2
4360 + es-errors: 1.3.0
4361 + es-shim-unscopables: 1.1.0
4362 +
4363 + arraybuffer.prototype.slice@1.0.4:
4364 + dependencies:
4365 + array-buffer-byte-length: 1.0.2
4366 + call-bind: 1.0.9
4367 + define-properties: 1.2.1
4368 + es-abstract: 1.24.2
4369 + es-errors: 1.3.0
4370 + get-intrinsic: 1.3.0
4371 + is-array-buffer: 3.0.5
4372 +
4373 + assertion-error@2.0.1: {}
4374 +
4375 + ast-types-flow@0.0.8: {}
4376 +
4377 + async-function@1.0.0: {}
4378 +
4379 + atomic-sleep@1.0.0: {}
4380 +
4381 + available-typed-arrays@1.0.7:
4382 + dependencies:
4383 + possible-typed-array-names: 1.1.0
4384 +
4385 + avvio@9.3.0:
4386 + dependencies:
4387 + '@fastify/error': 4.2.0
4388 + fastq: 1.20.3
4389 +
4390 + axe-core@4.13.0: {}
4391 +
4392 + axobject-query@4.1.0: {}
4393 +
4394 + balanced-match@1.0.2: {}
4395 +
4396 + balanced-match@4.0.4: {}
4397 +
4398 + baseline-browser-mapping@2.11.21: {}
4399 +
4400 + bintrees@1.0.2: {}
4401 +
4402 + boolbase@1.0.0: {}
4403 +
4404 + brace-expansion@1.1.18:
4405 + dependencies:
4406 + balanced-match: 1.0.2
4407 + concat-map: 0.0.1
4408 +
4409 + brace-expansion@5.0.9:
4410 + dependencies:
4411 + balanced-match: 4.0.4
4412 +
4413 + braces@3.0.3:
4414 + dependencies:
4415 + fill-range: 7.1.1
4416 +
4417 + browserslist@4.28.9:
4418 + dependencies:
4419 + baseline-browser-mapping: 2.11.21
4420 + caniuse-lite: 1.0.30001810
4421 + electron-to-chromium: 1.5.422
4422 + node-releases: 2.0.54
4423 + update-browserslist-db: 1.3.2(browserslist@4.28.9)
4424 +
4425 + cac@6.7.14: {}
4426 +
4427 + call-bind-apply-helpers@1.0.2:
4428 + dependencies:
4429 + es-errors: 1.3.0
4430 + function-bind: 1.1.2
4431 +
4432 + call-bind@1.0.9:
4433 + dependencies:
4434 + call-bind-apply-helpers: 1.0.2
4435 + es-define-property: 1.0.1
4436 + get-intrinsic: 1.3.0
4437 + set-function-length: 1.2.2
4438 +
4439 + call-bound@1.0.4:
4440 + dependencies:
4441 + call-bind-apply-helpers: 1.0.2
4442 + get-intrinsic: 1.3.0
4443 +
4444 + callsites@3.1.0: {}
4445 +
4446 + caniuse-lite@1.0.30001810: {}
4447 +
4448 + chai@5.3.3:
4449 + dependencies:
4450 + assertion-error: 2.0.1
4451 + check-error: 2.1.3
4452 + deep-eql: 5.0.2
4453 + loupe: 3.2.1
4454 + pathval: 2.0.1
4455 +
4456 + chalk@4.1.2:
4457 + dependencies:
4458 + ansi-styles: 4.3.0
4459 + supports-color: 7.2.0
4460 +
4461 + check-error@2.1.3: {}
4462 +
4463 + cheerio-select@2.1.0:
4464 + dependencies:
4465 + boolbase: 1.0.0
4466 + css-select: 5.2.2
4467 + css-what: 6.2.2
4468 + domelementtype: 2.3.0
4469 + domhandler: 5.0.3
4470 + domutils: 3.2.2
4471 +
4472 + cheerio@1.2.0:
4473 + dependencies:
4474 + cheerio-select: 2.1.0
4475 + dom-serializer: 2.0.0
4476 + domhandler: 5.0.3
4477 + domutils: 3.2.2
4478 + encoding-sniffer: 0.2.1
4479 + htmlparser2: 10.1.0
4480 + parse5: 7.3.0
4481 + parse5-htmlparser2-tree-adapter: 7.1.0
4482 + parse5-parser-stream: 7.1.2
4483 + undici: 7.29.1
4484 + whatwg-mimetype: 4.0.0
4485 +
4486 + client-only@0.0.1: {}
4487 +
4488 + cluster-key-slot@1.1.1: {}
4489 +
4490 + color-convert@2.0.1:
4491 + dependencies:
4492 + color-name: 1.1.4
4493 +
4494 + color-name@1.1.4: {}
4495 +
4496 + colorette@2.0.20: {}
4497 +
4498 + concat-map@0.0.1: {}
4499 +
4500 + convert-source-map@2.0.0: {}
4501 +
4502 + cookie@1.1.1: {}
4503 +
4504 + cross-spawn@7.0.6:
4505 + dependencies:
4506 + path-key: 3.1.1
4507 + shebang-command: 2.0.0
4508 + which: 2.0.2
4509 +
4510 + css-select@5.2.2:
4511 + dependencies:
4512 + boolbase: 1.0.0
4513 + css-what: 6.2.2
4514 + domhandler: 5.0.3
4515 + domutils: 3.2.2
4516 + nth-check: 2.1.1
4517 +
4518 + css-what@6.2.2: {}
4519 +
4520 + csstype@3.2.3: {}
4521 +
4522 + damerau-levenshtein@1.0.8: {}
4523 +
4524 + data-view-buffer@1.0.2:
4525 + dependencies:
4526 + call-bound: 1.0.4
4527 + es-errors: 1.3.0
4528 + is-data-view: 1.0.2
4529 +
4530 + data-view-byte-length@1.0.2:
4531 + dependencies:
4532 + call-bound: 1.0.4
4533 + es-errors: 1.3.0
4534 + is-data-view: 1.0.2
4535 +
4536 + data-view-byte-offset@1.0.1:
4537 + dependencies:
4538 + call-bound: 1.0.4
4539 + es-errors: 1.3.0
4540 + is-data-view: 1.0.2
4541 +
4542 + dateformat@4.6.3: {}
4543 +
4544 + debug@3.2.7:
4545 + dependencies:
4546 + ms: 2.1.3
4547 +
4548 + debug@4.4.3:
4549 + dependencies:
4550 + ms: 2.1.3
4551 +
4552 + deep-eql@5.0.2: {}
4553 +
4554 + deep-is@0.1.4: {}
4555 +
4556 + define-data-property@1.1.4:
4557 + dependencies:
4558 + es-define-property: 1.0.1
4559 + es-errors: 1.3.0
4560 + gopd: 1.2.0
4561 +
4562 + define-properties@1.2.1:
4563 + dependencies:
4564 + define-data-property: 1.1.4
4565 + has-property-descriptors: 1.0.2
4566 + object-keys: 1.1.1
4567 +
4568 + denque@2.1.0: {}
4569 +
4570 + dequal@2.0.3: {}
4571 +
4572 + detect-libc@2.1.2: {}
4573 +
4574 + diff@8.0.4: {}
4575 +
4576 + doctrine@2.1.0:
4577 + dependencies:
4578 + esutils: 2.0.3
4579 +
4580 + dom-serializer@2.0.0:
4581 + dependencies:
4582 + domelementtype: 2.3.0
4583 + domhandler: 5.0.3
4584 + entities: 4.5.0
4585 +
4586 + domelementtype@2.3.0: {}
4587 +
4588 + domhandler@5.0.3:
4589 + dependencies:
4590 + domelementtype: 2.3.0
4591 +
4592 + domutils@3.2.2:
4593 + dependencies:
4594 + dom-serializer: 2.0.0
4595 + domelementtype: 2.3.0
4596 + domhandler: 5.0.3
4597 +
4598 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0):
4599 + optionalDependencies:
4600 + '@opentelemetry/api': 1.9.1
4601 + '@types/pg': 8.23.1
4602 + pg: 8.23.0
4603 +
4604 + dunder-proto@1.0.1:
4605 + dependencies:
4606 + call-bind-apply-helpers: 1.0.2
4607 + es-errors: 1.3.0
4608 + gopd: 1.2.0
4609 +
4610 + duplexify@4.1.3:
4611 + dependencies:
4612 + end-of-stream: 1.4.5
4613 + inherits: 2.0.4
4614 + readable-stream: 3.6.2
4615 + stream-shift: 1.0.3
4616 +
4617 + electron-to-chromium@1.5.422: {}
4618 +
4619 + emoji-regex@9.2.2: {}
4620 +
4621 + encoding-sniffer@0.2.1:
4622 + dependencies:
4623 + iconv-lite: 0.6.3
4624 + whatwg-encoding: 3.1.1
4625 +
4626 + end-of-stream@1.4.5:
4627 + dependencies:
4628 + once: 1.4.0
4629 +
4630 + enhanced-resolve@5.24.5:
4631 + dependencies:
4632 + graceful-fs: 4.2.11
4633 + tapable: 2.3.3
4634 +
4635 + entities@4.5.0: {}
4636 +
4637 + entities@6.0.1: {}
4638 +
4639 + entities@7.0.1: {}
4640 +
4641 + es-abstract-get@1.0.0:
4642 + dependencies:
4643 + es-errors: 1.3.0
4644 + es-object-atoms: 1.1.2
4645 + is-callable: 1.2.7
4646 + object-inspect: 1.13.4
4647 +
4648 + es-abstract@1.24.2:
4649 + dependencies:
4650 + array-buffer-byte-length: 1.0.2
4651 + arraybuffer.prototype.slice: 1.0.4
4652 + available-typed-arrays: 1.0.7
4653 + call-bind: 1.0.9
4654 + call-bound: 1.0.4
4655 + data-view-buffer: 1.0.2
4656 + data-view-byte-length: 1.0.2
4657 + data-view-byte-offset: 1.0.1
4658 + es-define-property: 1.0.1
4659 + es-errors: 1.3.0
4660 + es-object-atoms: 1.1.2
4661 + es-set-tostringtag: 2.1.0
4662 + es-to-primitive: 1.3.4
4663 + function.prototype.name: 1.2.0
4664 + get-intrinsic: 1.3.0
4665 + get-proto: 1.0.1
4666 + get-symbol-description: 1.1.0
4667 + globalthis: 1.0.4
4668 + gopd: 1.2.0
4669 + has-property-descriptors: 1.0.2
4670 + has-proto: 1.2.0
4671 + has-symbols: 1.1.0
4672 + hasown: 2.0.4
4673 + internal-slot: 1.1.0
4674 + is-array-buffer: 3.0.5
4675 + is-callable: 1.2.7
4676 + is-data-view: 1.0.2
4677 + is-negative-zero: 2.0.3
4678 + is-regex: 1.2.1
4679 + is-set: 2.0.3
4680 + is-shared-array-buffer: 1.0.4
4681 + is-string: 1.1.1
4682 + is-typed-array: 1.1.15
4683 + is-weakref: 1.1.1
4684 + math-intrinsics: 1.1.0
4685 + object-inspect: 1.13.4
4686 + object-keys: 1.1.1
4687 + object.assign: 4.1.7
4688 + own-keys: 1.0.2
4689 + regexp.prototype.flags: 1.5.4
4690 + safe-array-concat: 1.1.4
4691 + safe-push-apply: 1.0.0
4692 + safe-regex-test: 1.1.0
4693 + set-proto: 1.0.0
4694 + stop-iteration-iterator: 1.1.0
4695 + string.prototype.trim: 1.2.11
4696 + string.prototype.trimend: 1.0.10
4697 + string.prototype.trimstart: 1.0.8
4698 + typed-array-buffer: 1.0.3
4699 + typed-array-byte-length: 1.0.3
4700 + typed-array-byte-offset: 1.0.4
4701 + typed-array-length: 1.0.8
4702 + unbox-primitive: 1.1.0
4703 + which-typed-array: 1.1.22
4704 +
4705 + es-define-property@1.0.1: {}
4706 +
4707 + es-errors@1.3.0: {}
4708 +
4709 + es-iterator-helpers@1.4.0:
4710 + dependencies:
4711 + call-bind: 1.0.9
4712 + call-bound: 1.0.4
4713 + define-properties: 1.2.1
4714 + es-abstract: 1.24.2
4715 + es-errors: 1.3.0
4716 + es-set-tostringtag: 2.1.0
4717 + function-bind: 1.1.2
4718 + get-intrinsic: 1.3.0
4719 + globalthis: 1.0.4
4720 + gopd: 1.2.0
4721 + has-property-descriptors: 1.0.2
4722 + has-proto: 1.2.0
4723 + has-symbols: 1.1.0
4724 + internal-slot: 1.1.0
4725 + iterator.prototype: 1.1.5
4726 + math-intrinsics: 1.1.0
4727 +
4728 + es-module-lexer@1.7.0: {}
4729 +
4730 + es-object-atoms@1.1.2:
4731 + dependencies:
4732 + es-errors: 1.3.0
4733 +
4734 + es-set-tostringtag@2.1.0:
4735 + dependencies:
4736 + es-errors: 1.3.0
4737 + get-intrinsic: 1.3.0
4738 + has-tostringtag: 1.0.2
4739 + hasown: 2.0.4
4740 +
4741 + es-shim-unscopables@1.1.0:
4742 + dependencies:
4743 + hasown: 2.0.4
4744 +
4745 + es-to-primitive@1.3.4:
4746 + dependencies:
4747 + es-abstract-get: 1.0.0
4748 + es-define-property: 1.0.1
4749 + es-errors: 1.3.0
4750 + is-callable: 1.2.7
4751 + is-date-object: 1.1.0
4752 + is-symbol: 1.1.1
4753 +
4754 + esbuild@0.28.2:
4755 + optionalDependencies:
4756 + '@esbuild/aix-ppc64': 0.28.2
4757 + '@esbuild/android-arm': 0.28.2
4758 + '@esbuild/android-arm64': 0.28.2
4759 + '@esbuild/android-x64': 0.28.2
4760 + '@esbuild/darwin-arm64': 0.28.2
4761 + '@esbuild/darwin-x64': 0.28.2
4762 + '@esbuild/freebsd-arm64': 0.28.2
4763 + '@esbuild/freebsd-x64': 0.28.2
4764 + '@esbuild/linux-arm': 0.28.2
4765 + '@esbuild/linux-arm64': 0.28.2
4766 + '@esbuild/linux-ia32': 0.28.2
4767 + '@esbuild/linux-loong64': 0.28.2
4768 + '@esbuild/linux-mips64el': 0.28.2
4769 + '@esbuild/linux-ppc64': 0.28.2
4770 + '@esbuild/linux-riscv64': 0.28.2
4771 + '@esbuild/linux-s390x': 0.28.2
4772 + '@esbuild/linux-x64': 0.28.2
4773 + '@esbuild/netbsd-arm64': 0.28.2
4774 + '@esbuild/netbsd-x64': 0.28.2
4775 + '@esbuild/openbsd-arm64': 0.28.2
4776 + '@esbuild/openbsd-x64': 0.28.2
4777 + '@esbuild/openharmony-arm64': 0.28.2
4778 + '@esbuild/sunos-x64': 0.28.2
4779 + '@esbuild/win32-arm64': 0.28.2
4780 + '@esbuild/win32-ia32': 0.28.2
4781 + '@esbuild/win32-x64': 0.28.2
4782 +
4783 + escalade@3.2.0: {}
4784 +
4785 + escape-string-regexp@4.0.0: {}
4786 +
4787 + eslint-config-next@16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
4788 + dependencies:
4789 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0))
4790 + eslint: 9.39.5(jiti@2.7.0)
4791 + eslint-import-resolver-node: 0.3.10
4792 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
4793 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
4794 + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
4795 + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
4796 + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
4797 + globals: 16.4.0
4798 + typescript-eslint: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4799 + optionalDependencies:
4800 + typescript: 5.9.3
4801 + transitivePeerDependencies:
4802 + - '@typescript-eslint/parser'
4803 + - eslint-import-resolver-webpack
4804 + - eslint-plugin-import-x
4805 + - supports-color
4806 +
4807 + eslint-import-resolver-node@0.3.10:
4808 + dependencies:
4809 + debug: 3.2.7
4810 + is-core-module: 2.16.2
4811 + resolve: 2.0.0-next.7
4812 + transitivePeerDependencies:
4813 + - supports-color
4814 +
4815 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
4816 + dependencies:
4817 + '@nolyfill/is-core-module': 1.0.39
4818 + debug: 4.4.3
4819 + eslint: 9.39.5(jiti@2.7.0)
4820 + get-tsconfig: 4.14.3
4821 + is-bun-module: 2.0.0
4822 + stable-hash: 0.0.5
4823 + tinyglobby: 0.2.17
4824 + unrs-resolver: 1.12.2
4825 + optionalDependencies:
4826 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
4827 + transitivePeerDependencies:
4828 + - supports-color
4829 +
4830 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
4831 + dependencies:
4832 + debug: 3.2.7
4833 + optionalDependencies:
4834 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4835 + eslint: 9.39.5(jiti@2.7.0)
4836 + eslint-import-resolver-node: 0.3.10
4837 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
4838 + transitivePeerDependencies:
4839 + - supports-color
4840 +
4841 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
4842 + dependencies:
4843 + '@rtsao/scc': 1.1.0
4844 + array-includes: 3.1.9
4845 + array.prototype.findlastindex: 1.2.6
4846 + array.prototype.flat: 1.3.3
4847 + array.prototype.flatmap: 1.3.3
4848 + debug: 3.2.7
4849 + doctrine: 2.1.0
4850 + eslint: 9.39.5(jiti@2.7.0)
4851 + eslint-import-resolver-node: 0.3.10
4852 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
4853 + hasown: 2.0.4
4854 + is-core-module: 2.16.2
4855 + is-glob: 4.0.3
4856 + minimatch: 3.1.5
4857 + object.fromentries: 2.0.8
4858 + object.groupby: 1.0.3
4859 + object.values: 1.2.1
4860 + semver: 6.3.1
4861 + string.prototype.trimend: 1.0.10
4862 + tsconfig-paths: 3.15.0
4863 + optionalDependencies:
4864 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4865 + transitivePeerDependencies:
4866 + - eslint-import-resolver-typescript
4867 + - eslint-import-resolver-webpack
4868 + - supports-color
4869 +
4870 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)):
4871 + dependencies:
4872 + aria-query: 5.3.2
4873 + array-includes: 3.1.9
4874 + array.prototype.flatmap: 1.3.3
4875 + ast-types-flow: 0.0.8
4876 + axe-core: 4.13.0
4877 + axobject-query: 4.1.0
4878 + damerau-levenshtein: 1.0.8
4879 + emoji-regex: 9.2.2
4880 + eslint: 9.39.5(jiti@2.7.0)
4881 + hasown: 2.0.4
4882 + jsx-ast-utils: 3.3.5
4883 + language-tags: 1.0.9
4884 + minimatch: 3.1.5
4885 + object.fromentries: 2.0.8
4886 + safe-regex-test: 1.1.0
4887 + string.prototype.includes: 2.0.1
4888 +
4889 + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)):
4890 + dependencies:
4891 + '@babel/core': 7.29.7
4892 + '@babel/parser': 7.29.8
4893 + eslint: 9.39.5(jiti@2.7.0)
4894 + hermes-parser: 0.25.1
4895 + zod: 4.5.4
4896 + zod-validation-error: 4.0.2(zod@4.5.4)
4897 + transitivePeerDependencies:
4898 + - supports-color
4899 +
4900 + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)):
4901 + dependencies:
4902 + array-includes: 3.1.9
4903 + array.prototype.findlast: 1.2.5
4904 + array.prototype.flatmap: 1.3.3
4905 + array.prototype.tosorted: 1.1.4
4906 + doctrine: 2.1.0
4907 + es-iterator-helpers: 1.4.0
4908 + eslint: 9.39.5(jiti@2.7.0)
4909 + estraverse: 5.3.0
4910 + hasown: 2.0.4
4911 + jsx-ast-utils: 3.3.5
4912 + minimatch: 3.1.5
4913 + object.entries: 1.1.9
4914 + object.fromentries: 2.0.8
4915 + object.values: 1.2.1
4916 + prop-types: 15.8.1
4917 + resolve: 2.0.0-next.7
4918 + semver: 6.3.1
4919 + string.prototype.matchall: 4.1.0
4920 + string.prototype.repeat: 1.0.0
4921 +
4922 + eslint-scope@8.4.0:
4923 + dependencies:
4924 + esrecurse: 4.3.0
4925 + estraverse: 5.3.0
4926 +
4927 + eslint-visitor-keys@3.4.3: {}
4928 +
4929 + eslint-visitor-keys@4.2.1: {}
4930 +
4931 + eslint-visitor-keys@5.0.1: {}
4932 +
4933 + eslint@9.39.5(jiti@2.7.0):
4934 + dependencies:
4935 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
4936 + '@eslint-community/regexpp': 4.12.2
4937 + '@eslint/config-array': 0.21.2
4938 + '@eslint/config-helpers': 0.4.2
4939 + '@eslint/core': 0.17.0
4940 + '@eslint/eslintrc': 3.3.7
4941 + '@eslint/js': 9.39.5
4942 + '@eslint/plugin-kit': 0.4.1
4943 + '@humanfs/node': 0.16.8
4944 + '@humanwhocodes/module-importer': 1.0.1
4945 + '@humanwhocodes/retry': 0.4.3
4946 + '@types/estree': 1.0.9
4947 + ajv: 6.15.0
4948 + chalk: 4.1.2
4949 + cross-spawn: 7.0.6
4950 + debug: 4.4.3
4951 + escape-string-regexp: 4.0.0
4952 + eslint-scope: 8.4.0
4953 + eslint-visitor-keys: 4.2.1
4954 + espree: 10.4.0
4955 + esquery: 1.7.0
4956 + esutils: 2.0.3
4957 + fast-deep-equal: 3.1.3
4958 + file-entry-cache: 8.0.0
4959 + find-up: 5.0.0
4960 + glob-parent: 6.0.2
4961 + ignore: 5.3.2
4962 + imurmurhash: 0.1.4
4963 + is-glob: 4.0.3
4964 + json-stable-stringify-without-jsonify: 1.0.1
4965 + lodash.merge: 4.6.2
4966 + minimatch: 3.1.5
4967 + natural-compare: 1.4.0
4968 + optionator: 0.9.4
4969 + optionalDependencies:
4970 + jiti: 2.7.0
4971 + transitivePeerDependencies:
4972 + - supports-color
4973 +
4974 + espree@10.4.0:
4975 + dependencies:
4976 + acorn: 8.18.0
4977 + acorn-jsx: 5.3.2(acorn@8.18.0)
4978 + eslint-visitor-keys: 4.2.1
4979 +
4980 + esquery@1.7.0:
4981 + dependencies:
4982 + estraverse: 5.3.0
4983 +
4984 + esrecurse@4.3.0:
4985 + dependencies:
4986 + estraverse: 5.3.0
4987 +
4988 + estraverse@5.3.0: {}
4989 +
4990 + estree-walker@3.0.3:
4991 + dependencies:
4992 + '@types/estree': 1.0.9
4993 +
4994 + esutils@2.0.3: {}
4995 +
4996 + expect-type@1.4.0: {}
4997 +
4998 + fast-content-type-parse@3.0.0: {}
4999 +
5000 + fast-copy@4.1.1: {}
5001 +
5002 + fast-decode-uri-component@1.0.1: {}
5003 +
5004 + fast-deep-equal@3.1.3: {}
5005 +
5006 + fast-glob@3.3.1:
5007 + dependencies:
5008 + '@nodelib/fs.stat': 2.0.5
5009 + '@nodelib/fs.walk': 1.2.8
5010 + glob-parent: 5.1.2
5011 + merge2: 1.4.1
5012 + micromatch: 4.0.8
5013 +
5014 + fast-json-stable-stringify@2.1.0: {}
5015 +
5016 + fast-json-stringify@7.0.1:
5017 + dependencies:
5018 + '@fastify/merge-json-schemas': 0.2.1
5019 + ajv: 8.20.0
5020 + ajv-formats: 3.0.1(ajv@8.20.0)
5021 + fast-uri: 4.1.4
5022 + json-schema-ref-resolver: 3.0.0
5023 + rfdc: 1.4.1
5024 +
5025 + fast-levenshtein@2.0.6: {}
5026 +
5027 + fast-querystring@1.1.2:
5028 + dependencies:
5029 + fast-decode-uri-component: 1.0.1
5030 +
5031 + fast-safe-stringify@2.1.1: {}
5032 +
5033 + fast-sha256@1.3.0: {}
5034 +
5035 + fast-uri@3.1.7: {}
5036 +
5037 + fast-uri@4.1.4: {}
5038 +
5039 + fast-xml-builder@1.3.1:
5040 + dependencies:
5041 + path-expression-matcher: 1.6.2
5042 + xml-naming: 0.3.0
5043 +
5044 + fast-xml-parser@5.11.1:
5045 + dependencies:
5046 + '@nodable/entities': 3.0.0
5047 + fast-xml-builder: 1.3.1
5048 + is-unsafe: 2.0.2
5049 + path-expression-matcher: 1.6.2
5050 + strnum: 2.4.2
5051 + xml-naming: 0.3.0
5052 +
5053 + fastify-plugin@5.1.0: {}
5054 +
5055 + fastify-plugin@6.0.0: {}
5056 +
5057 + fastify@5.12.3:
5058 + dependencies:
5059 + '@fastify/ajv-compiler': 4.0.6
5060 + '@fastify/error': 4.2.0
5061 + '@fastify/fast-json-stringify-compiler': 5.1.0
5062 + '@fastify/proxy-addr': 5.1.0
5063 + abstract-logging: 2.0.1
5064 + avvio: 9.3.0
5065 + fast-json-stringify: 7.0.1
5066 + find-my-way: 9.9.0
5067 + light-my-request: 6.6.0
5068 + pino: 9.14.0
5069 + process-warning: 5.1.0
5070 + rfdc: 1.4.1
5071 + secure-json-parse: 4.1.0
5072 + semver: 7.8.5
5073 + toad-cache: 3.7.4
5074 +
5075 + fastq@1.20.3:
5076 + dependencies:
5077 + reusify: 1.1.0
5078 +
5079 + fdir@6.5.0(picomatch@4.0.7):
5080 + optionalDependencies:
5081 + picomatch: 4.0.7
5082 +
5083 + file-entry-cache@8.0.0:
5084 + dependencies:
5085 + flat-cache: 4.0.1
5086 +
5087 + fill-range@7.1.1:
5088 + dependencies:
5089 + to-regex-range: 5.0.1
5090 +
5091 + find-my-way@9.9.0:
5092 + dependencies:
5093 + fast-deep-equal: 3.1.3
5094 + fast-querystring: 1.1.2
5095 + safe-regex2: 5.1.1
5096 +
5097 + find-up@5.0.0:
5098 + dependencies:
5099 + locate-path: 6.0.0
5100 + path-exists: 4.0.0
5101 +
5102 + flat-cache@4.0.1:
5103 + dependencies:
5104 + flatted: 3.4.4
5105 + keyv: 4.5.4
5106 +
5107 + flatted@3.4.4: {}
5108 +
5109 + for-each@0.3.5:
5110 + dependencies:
5111 + is-callable: 1.2.7
5112 +
5113 + fsevents@2.3.3:
5114 + optional: true
5115 +
5116 + function-bind@1.1.2: {}
5117 +
5118 + function.prototype.name@1.2.0:
5119 + dependencies:
5120 + call-bind: 1.0.9
5121 + call-bound: 1.0.4
5122 + es-define-property: 1.0.1
5123 + es-errors: 1.3.0
5124 + functions-have-names: 1.2.3
5125 + has-property-descriptors: 1.0.2
5126 + hasown: 2.0.4
5127 + is-callable: 1.2.7
5128 + is-document.all: 1.0.0
5129 +
5130 + functions-have-names@1.2.3: {}
5131 +
5132 + generator-function@2.0.1: {}
5133 +
5134 + gensync@1.0.0-beta.2: {}
5135 +
5136 + get-intrinsic@1.3.0:
5137 + dependencies:
5138 + call-bind-apply-helpers: 1.0.2
5139 + es-define-property: 1.0.1
5140 + es-errors: 1.3.0
5141 + es-object-atoms: 1.1.2
5142 + function-bind: 1.1.2
5143 + get-proto: 1.0.1
5144 + gopd: 1.2.0
5145 + has-symbols: 1.1.0
5146 + hasown: 2.0.4
5147 + math-intrinsics: 1.1.0
5148 +
5149 + get-proto@1.0.1:
5150 + dependencies:
5151 + dunder-proto: 1.0.1
5152 + es-object-atoms: 1.1.2
5153 +
5154 + get-symbol-description@1.1.0:
5155 + dependencies:
5156 + call-bound: 1.0.4
5157 + es-errors: 1.3.0
5158 + get-intrinsic: 1.3.0
5159 +
5160 + get-tsconfig@4.14.3:
5161 + dependencies:
5162 + resolve-pkg-maps: 1.0.0
5163 +
5164 + glob-parent@5.1.2:
5165 + dependencies:
5166 + is-glob: 4.0.3
5167 +
5168 + glob-parent@6.0.2:
5169 + dependencies:
5170 + is-glob: 4.0.3
5171 +
5172 + globals@14.0.0: {}
5173 +
5174 + globals@16.4.0: {}
5175 +
5176 + globalthis@1.0.4:
5177 + dependencies:
5178 + define-properties: 1.2.1
5179 + gopd: 1.2.0
5180 +
5181 + gopd@1.2.0: {}
5182 +
5183 + graceful-fs@4.2.11: {}
5184 +
5185 + has-bigints@1.1.0: {}
5186 +
5187 + has-flag@4.0.0: {}
5188 +
5189 + has-property-descriptors@1.0.2:
5190 + dependencies:
5191 + es-define-property: 1.0.1
5192 +
5193 + has-proto@1.2.0:
5194 + dependencies:
5195 + dunder-proto: 1.0.1
5196 +
5197 + has-symbols@1.1.0: {}
5198 +
5199 + has-tostringtag@1.0.2:
5200 + dependencies:
5201 + has-symbols: 1.1.0
5202 +
5203 + hasown@2.0.4:
5204 + dependencies:
5205 + function-bind: 1.1.2
5206 +
5207 + help-me@5.0.0: {}
5208 +
5209 + hermes-estree@0.25.1: {}
5210 +
5211 + hermes-parser@0.25.1:
5212 + dependencies:
5213 + hermes-estree: 0.25.1
5214 +
5215 + htmlparser2@10.1.0:
5216 + dependencies:
5217 + domelementtype: 2.3.0
5218 + domhandler: 5.0.3
5219 + domutils: 3.2.2
5220 + entities: 7.0.1
5221 +
5222 + iconv-lite@0.6.3:
5223 + dependencies:
5224 + safer-buffer: 2.1.2
5225 +
5226 + ignore@5.3.2: {}
5227 +
5228 + ignore@7.0.8: {}
5229 +
5230 + import-fresh@3.3.1:
5231 + dependencies:
5232 + parent-module: 1.0.1
5233 + resolve-from: 4.0.0
5234 +
5235 + imurmurhash@0.1.4: {}
5236 +
5237 + inherits@2.0.4: {}
5238 +
5239 + internal-slot@1.1.0:
5240 + dependencies:
5241 + es-errors: 1.3.0
5242 + hasown: 2.0.4
5243 + side-channel: 1.1.1
5244 +
5245 + ioredis@5.11.1:
5246 + dependencies:
5247 + '@ioredis/commands': 1.10.0
5248 + cluster-key-slot: 1.1.1
5249 + debug: 4.4.3
5250 + denque: 2.1.0
5251 + redis-errors: 1.2.0
5252 + redis-parser: 3.0.0
5253 + standard-as-callback: 2.1.0
5254 + transitivePeerDependencies:
5255 + - supports-color
5256 +
5257 + ipaddr.js@2.5.0: {}
5258 +
5259 + is-array-buffer@3.0.5:
5260 + dependencies:
5261 + call-bind: 1.0.9
5262 + call-bound: 1.0.4
5263 + get-intrinsic: 1.3.0
5264 +
5265 + is-async-function@2.1.1:
5266 + dependencies:
5267 + async-function: 1.0.0
5268 + call-bound: 1.0.4
5269 + get-proto: 1.0.1
5270 + has-tostringtag: 1.0.2
5271 + safe-regex-test: 1.1.0
5272 +
5273 + is-bigint@1.1.0:
5274 + dependencies:
5275 + has-bigints: 1.1.0
5276 +
5277 + is-boolean-object@1.2.2:
5278 + dependencies:
5279 + call-bound: 1.0.4
5280 + has-tostringtag: 1.0.2
5281 +
5282 + is-bun-module@2.0.0:
5283 + dependencies:
5284 + semver: 7.8.5
5285 +
5286 + is-callable@1.2.7: {}
5287 +
5288 + is-core-module@2.16.2:
5289 + dependencies:
5290 + hasown: 2.0.4
5291 +
5292 + is-data-view@1.0.2:
5293 + dependencies:
5294 + call-bound: 1.0.4
5295 + get-intrinsic: 1.3.0
5296 + is-typed-array: 1.1.15
5297 +
5298 + is-date-object@1.1.0:
5299 + dependencies:
5300 + call-bound: 1.0.4
5301 + has-tostringtag: 1.0.2
5302 +
5303 + is-document.all@1.0.0:
5304 + dependencies:
5305 + call-bound: 1.0.4
5306 +
5307 + is-extglob@2.1.1: {}
5308 +
5309 + is-finalizationregistry@1.1.1:
5310 + dependencies:
5311 + call-bound: 1.0.4
5312 +
5313 + is-generator-function@1.1.2:
5314 + dependencies:
5315 + call-bound: 1.0.4
5316 + generator-function: 2.0.1
5317 + get-proto: 1.0.1
5318 + has-tostringtag: 1.0.2
5319 + safe-regex-test: 1.1.0
5320 +
5321 + is-glob@4.0.3:
5322 + dependencies:
5323 + is-extglob: 2.1.1
5324 +
5325 + is-map@2.0.3: {}
5326 +
5327 + is-negative-zero@2.0.3: {}
5328 +
5329 + is-number-object@1.1.1:
5330 + dependencies:
5331 + call-bound: 1.0.4
5332 + has-tostringtag: 1.0.2
5333 +
5334 + is-number@7.0.0: {}
5335 +
5336 + is-regex@1.2.1:
5337 + dependencies:
5338 + call-bound: 1.0.4
5339 + gopd: 1.2.0
5340 + has-tostringtag: 1.0.2
5341 + hasown: 2.0.4
5342 +
5343 + is-set@2.0.3: {}
5344 +
5345 + is-shared-array-buffer@1.0.4:
5346 + dependencies:
5347 + call-bound: 1.0.4
5348 +
5349 + is-string@1.1.1:
5350 + dependencies:
5351 + call-bound: 1.0.4
5352 + has-tostringtag: 1.0.2
5353 +
5354 + is-symbol@1.1.1:
5355 + dependencies:
5356 + call-bound: 1.0.4
5357 + has-symbols: 1.1.0
5358 + safe-regex-test: 1.1.0
5359 +
5360 + is-typed-array@1.1.15:
5361 + dependencies:
5362 + which-typed-array: 1.1.22
5363 +
5364 + is-unsafe@2.0.2: {}
5365 +
5366 + is-weakmap@2.0.2: {}
5367 +
5368 + is-weakref@1.1.1:
5369 + dependencies:
5370 + call-bound: 1.0.4
5371 +
5372 + is-weakset@2.0.4:
5373 + dependencies:
5374 + call-bound: 1.0.4
5375 + get-intrinsic: 1.3.0
5376 +
5377 + isarray@2.0.5: {}
5378 +
5379 + isexe@2.0.0: {}
5380 +
5381 + iterator.prototype@1.1.5:
5382 + dependencies:
5383 + define-data-property: 1.1.4
5384 + es-object-atoms: 1.1.2
5385 + get-intrinsic: 1.3.0
5386 + get-proto: 1.0.1
5387 + has-symbols: 1.1.0
5388 + set-function-name: 2.0.2
5389 +
5390 + jiti@2.7.0: {}
5391 +
5392 + joycon@3.1.1: {}
5393 +
5394 + js-tokens@4.0.0: {}
5395 +
5396 + js-tokens@9.0.1: {}
5397 +
5398 + js-yaml@4.3.2:
5399 + dependencies:
5400 + argparse: 2.0.1
5401 +
5402 + jsesc@3.1.0: {}
5403 +
5404 + json-buffer@3.0.1: {}
5405 +
5406 + json-schema-ref-resolver@3.0.0:
5407 + dependencies:
5408 + dequal: 2.0.3
5409 +
5410 + json-schema-to-ts@3.1.1:
5411 + dependencies:
5412 + '@babel/runtime': 7.29.7
5413 + ts-algebra: 2.0.0
5414 +
5415 + json-schema-traverse@0.4.1: {}
5416 +
5417 + json-schema-traverse@1.0.0: {}
5418 +
5419 + json-stable-stringify-without-jsonify@1.0.1: {}
5420 +
5421 + json5@1.0.2:
5422 + dependencies:
5423 + minimist: 1.2.8
5424 +
5425 + json5@2.2.3: {}
5426 +
5427 + jsx-ast-utils@3.3.5:
5428 + dependencies:
5429 + array-includes: 3.1.9
5430 + array.prototype.flat: 1.3.3
5431 + object.assign: 4.1.7
5432 + object.values: 1.2.1
5433 +
5434 + keyv@4.5.4:
5435 + dependencies:
5436 + json-buffer: 3.0.1
5437 +
5438 + language-subtag-registry@0.3.23: {}
5439 +
5440 + language-tags@1.0.9:
5441 + dependencies:
5442 + language-subtag-registry: 0.3.23
5443 +
5444 + levn@0.4.1:
5445 + dependencies:
5446 + prelude-ls: 1.2.1
5447 + type-check: 0.4.0
5448 +
5449 + light-my-request@6.6.0:
5450 + dependencies:
5451 + cookie: 1.1.1
5452 + process-warning: 4.0.1
5453 + set-cookie-parser: 2.7.2
5454 +
5455 + lightningcss-android-arm64@1.32.0:
5456 + optional: true
5457 +
5458 + lightningcss-darwin-arm64@1.32.0:
5459 + optional: true
5460 +
5461 + lightningcss-darwin-x64@1.32.0:
5462 + optional: true
5463 +
5464 + lightningcss-freebsd-x64@1.32.0:
5465 + optional: true
5466 +
5467 + lightningcss-linux-arm-gnueabihf@1.32.0:
5468 + optional: true
5469 +
5470 + lightningcss-linux-arm64-gnu@1.32.0:
5471 + optional: true
5472 +
5473 + lightningcss-linux-arm64-musl@1.32.0:
5474 + optional: true
5475 +
5476 + lightningcss-linux-x64-gnu@1.32.0:
5477 + optional: true
5478 +
5479 + lightningcss-linux-x64-musl@1.32.0:
5480 + optional: true
5481 +
5482 + lightningcss-win32-arm64-msvc@1.32.0:
5483 + optional: true
5484 +
5485 + lightningcss-win32-x64-msvc@1.32.0:
5486 + optional: true
5487 +
5488 + lightningcss@1.32.0:
5489 + dependencies:
5490 + detect-libc: 2.1.2
5491 + optionalDependencies:
5492 + lightningcss-android-arm64: 1.32.0
5493 + lightningcss-darwin-arm64: 1.32.0
5494 + lightningcss-darwin-x64: 1.32.0
5495 + lightningcss-freebsd-x64: 1.32.0
5496 + lightningcss-linux-arm-gnueabihf: 1.32.0
5497 + lightningcss-linux-arm64-gnu: 1.32.0
5498 + lightningcss-linux-arm64-musl: 1.32.0
5499 + lightningcss-linux-x64-gnu: 1.32.0
5500 + lightningcss-linux-x64-musl: 1.32.0
5501 + lightningcss-win32-arm64-msvc: 1.32.0
5502 + lightningcss-win32-x64-msvc: 1.32.0
5503 +
5504 + locate-path@6.0.0:
5505 + dependencies:
5506 + p-locate: 5.0.0
5507 +
5508 + lodash.merge@4.6.2: {}
5509 +
5510 + loose-envify@1.4.0:
5511 + dependencies:
5512 + js-tokens: 4.0.0
5513 +
5514 + loupe@3.2.1: {}
5515 +
5516 + lru-cache@5.1.1:
5517 + dependencies:
5518 + yallist: 3.1.1
5519 +
5520 + lucide-react@1.41.0(react@19.2.8):
5521 + dependencies:
5522 + react: 19.2.8
5523 +
5524 + magic-string@0.30.21:
5525 + dependencies:
5526 + '@jridgewell/sourcemap-codec': 1.6.0
5527 +
5528 + math-intrinsics@1.1.0: {}
5529 +
5530 + merge2@1.4.1: {}
5531 +
5532 + micromatch@4.0.8:
5533 + dependencies:
5534 + braces: 3.0.3
5535 + picomatch: 2.3.2
5536 +
5537 + minimatch@10.2.6:
5538 + dependencies:
5539 + brace-expansion: 5.0.9
5540 +
5541 + minimatch@3.1.5:
5542 + dependencies:
5543 + brace-expansion: 1.1.18
5544 +
5545 + minimist@1.2.8: {}
5546 +
5547 + ms@2.1.3: {}
5548 +
5549 + nanoid@3.3.18: {}
5550 +
5551 + napi-postinstall@0.3.4: {}
5552 +
5553 + natural-compare@1.4.0: {}
5554 +
5555 + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
5556 + dependencies:
5557 + react: 19.2.8
5558 + react-dom: 19.2.8(react@19.2.8)
5559 +
5560 + next@16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
5561 + dependencies:
5562 + '@next/env': 16.3.4
5563 + '@swc/helpers': 0.5.23
5564 + baseline-browser-mapping: 2.11.21
5565 + caniuse-lite: 1.0.30001810
5566 + postcss: 8.5.23
5567 + react: 19.2.8
5568 + react-dom: 19.2.8(react@19.2.8)
5569 + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8)
5570 + optionalDependencies:
5571 + '@next/swc-darwin-arm64': 16.3.4
5572 + '@next/swc-darwin-x64': 16.3.4
5573 + '@next/swc-linux-arm64-gnu': 16.3.4
5574 + '@next/swc-linux-arm64-musl': 16.3.4
5575 + '@next/swc-linux-x64-gnu': 16.3.4
5576 + '@next/swc-linux-x64-musl': 16.3.4
5577 + '@next/swc-win32-arm64-msvc': 16.3.4
5578 + '@next/swc-win32-x64-msvc': 16.3.4
5579 + '@opentelemetry/api': 1.9.1
5580 + sharp: 0.35.4(@types/node@24.13.3)
5581 + transitivePeerDependencies:
5582 + - '@babel/core'
5583 + - '@types/node'
5584 + - babel-plugin-macros
5585 +
5586 + node-exports-info@1.6.2:
5587 + dependencies:
5588 + array.prototype.flatmap: 1.3.3
5589 + es-errors: 1.3.0
5590 + object.entries: 1.1.9
5591 + semver: 6.3.1
5592 +
5593 + node-releases@2.0.54: {}
5594 +
5595 + nth-check@2.1.1:
5596 + dependencies:
5597 + boolbase: 1.0.0
5598 +
5599 + object-assign@4.1.1: {}
5600 +
5601 + object-inspect@1.13.4: {}
5602 +
5603 + object-keys@1.1.1: {}
5604 +
5605 + object.assign@4.1.7:
5606 + dependencies:
5607 + call-bind: 1.0.9
5608 + call-bound: 1.0.4
5609 + define-properties: 1.2.1
5610 + es-object-atoms: 1.1.2
5611 + has-symbols: 1.1.0
5612 + object-keys: 1.1.1
5613 +
5614 + object.entries@1.1.9:
5615 + dependencies:
5616 + call-bind: 1.0.9
5617 + call-bound: 1.0.4
5618 + define-properties: 1.2.1
5619 + es-object-atoms: 1.1.2
5620 +
5621 + object.fromentries@2.0.8:
5622 + dependencies:
5623 + call-bind: 1.0.9
5624 + define-properties: 1.2.1
5625 + es-abstract: 1.24.2
5626 + es-object-atoms: 1.1.2
5627 +
5628 + object.groupby@1.0.3:
5629 + dependencies:
5630 + call-bind: 1.0.9
5631 + define-properties: 1.2.1
5632 + es-abstract: 1.24.2
5633 +
5634 + object.values@1.2.1:
5635 + dependencies:
5636 + call-bind: 1.0.9
5637 + call-bound: 1.0.4
5638 + define-properties: 1.2.1
5639 + es-object-atoms: 1.1.2
5640 +
5641 + on-exit-leak-free@2.1.2: {}
5642 +
5643 + once@1.4.0:
5644 + dependencies:
5645 + wrappy: 1.0.2
5646 +
5647 + optionator@0.9.4:
5648 + dependencies:
5649 + deep-is: 0.1.4
5650 + fast-levenshtein: 2.0.6
5651 + levn: 0.4.1
5652 + prelude-ls: 1.2.1
5653 + type-check: 0.4.0
5654 + word-wrap: 1.2.5
5655 +
5656 + own-keys@1.0.2:
5657 + dependencies:
5658 + call-bound: 1.0.4
5659 + get-intrinsic: 1.3.0
5660 + object-keys: 1.1.1
5661 + safe-push-apply: 1.0.0
5662 +
5663 + p-limit@3.1.0:
5664 + dependencies:
5665 + yocto-queue: 0.1.0
5666 +
5667 + p-locate@5.0.0:
5668 + dependencies:
5669 + p-limit: 3.1.0
5670 +
5671 + parent-module@1.0.1:
5672 + dependencies:
5673 + callsites: 3.1.0
5674 +
5675 + parse5-htmlparser2-tree-adapter@7.1.0:
5676 + dependencies:
5677 + domhandler: 5.0.3
5678 + parse5: 7.3.0
5679 +
5680 + parse5-parser-stream@7.1.2:
5681 + dependencies:
5682 + parse5: 7.3.0
5683 +
5684 + parse5@7.3.0:
5685 + dependencies:
5686 + entities: 6.0.1
5687 +
5688 + path-exists@4.0.0: {}
5689 +
5690 + path-expression-matcher@1.6.2: {}
5691 +
5692 + path-key@3.1.1: {}
5693 +
5694 + path-parse@1.0.7: {}
5695 +
5696 + pathe@2.0.3: {}
5697 +
5698 + pathval@2.0.1: {}
5699 +
5700 + pg-cloudflare@1.4.0:
5701 + optional: true
5702 +
5703 + pg-connection-string@2.14.0: {}
5704 +
5705 + pg-int8@1.0.1: {}
5706 +
5707 + pg-pool@3.14.0(pg@8.23.0):
5708 + dependencies:
5709 + pg: 8.23.0
5710 +
5711 + pg-protocol@1.16.0: {}
5712 +
5713 + pg-types@2.2.0:
5714 + dependencies:
5715 + pg-int8: 1.0.1
5716 + postgres-array: 2.0.0
5717 + postgres-bytea: 1.0.1
5718 + postgres-date: 1.0.7
5719 + postgres-interval: 1.2.0
5720 +
5721 + pg@8.23.0:
5722 + dependencies:
5723 + pg-connection-string: 2.14.0
5724 + pg-pool: 3.14.0(pg@8.23.0)
5725 + pg-protocol: 1.16.0
5726 + pg-types: 2.2.0
5727 + pgpass: 1.0.5
5728 + optionalDependencies:
5729 + pg-cloudflare: 1.4.0
5730 +
5731 + pgpass@1.0.5:
5732 + dependencies:
5733 + split2: 4.2.0
5734 +
5735 + picocolors@1.1.1: {}
5736 +
5737 + picomatch@2.3.2: {}
5738 +
5739 + picomatch@4.0.7: {}
5740 +
5741 + pino-abstract-transport@2.0.0:
5742 + dependencies:
5743 + split2: 4.2.0
5744 +
5745 + pino-abstract-transport@3.0.0:
5746 + dependencies:
5747 + split2: 4.2.0
5748 +
5749 + pino-pretty@13.1.3:
5750 + dependencies:
5751 + colorette: 2.0.20
5752 + dateformat: 4.6.3
5753 + fast-copy: 4.1.1
5754 + fast-safe-stringify: 2.1.1
5755 + help-me: 5.0.0
5756 + joycon: 3.1.1
5757 + minimist: 1.2.8
5758 + on-exit-leak-free: 2.1.2
5759 + pino-abstract-transport: 3.0.0
5760 + pump: 3.0.4
5761 + secure-json-parse: 4.1.0
5762 + sonic-boom: 4.2.1
5763 + strip-json-comments: 5.0.3
5764 +
5765 + pino-std-serializers@7.1.0: {}
5766 +
5767 + pino@9.14.0:
5768 + dependencies:
5769 + '@pinojs/redact': 0.4.0
5770 + atomic-sleep: 1.0.0
5771 + on-exit-leak-free: 2.1.2
5772 + pino-abstract-transport: 2.0.0
5773 + pino-std-serializers: 7.1.0
5774 + process-warning: 5.1.0
5775 + quick-format-unescaped: 4.0.4
5776 + real-require: 0.2.0
5777 + safe-stable-stringify: 2.5.0
5778 + sonic-boom: 4.2.1
5779 + thread-stream: 3.2.0
5780 +
5781 + possible-typed-array-names@1.1.0: {}
5782 +
5783 + postcss@8.5.23:
5784 + dependencies:
5785 + nanoid: 3.3.18
5786 + picocolors: 1.1.1
5787 + source-map-js: 1.2.1
5788 +
5789 + postcss@8.5.28:
5790 + dependencies:
5791 + nanoid: 3.3.18
5792 + picocolors: 1.1.1
5793 + source-map-js: 1.2.1
5794 +
5795 + postgres-array@2.0.0: {}
5796 +
5797 + postgres-bytea@1.0.1: {}
5798 +
5799 + postgres-date@1.0.7: {}
5800 +
5801 + postgres-interval@1.2.0:
5802 + dependencies:
5803 + xtend: 4.0.2
5804 +
5805 + prelude-ls@1.2.1: {}
5806 +
5807 + process-warning@4.0.1: {}
5808 +
5809 + process-warning@5.1.0: {}
5810 +
5811 + prom-client@15.1.3:
5812 + dependencies:
5813 + '@opentelemetry/api': 1.9.1
5814 + tdigest: 0.1.3
5815 +
5816 + prop-types@15.8.1:
5817 + dependencies:
5818 + loose-envify: 1.4.0
5819 + object-assign: 4.1.1
5820 + react-is: 16.13.1
5821 +
5822 + pump@3.0.4:
5823 + dependencies:
5824 + end-of-stream: 1.4.5
5825 + once: 1.4.0
5826 +
5827 + punycode@2.3.1: {}
5828 +
5829 + queue-microtask@1.2.3: {}
5830 +
5831 + quick-format-unescaped@4.0.4: {}
5832 +
5833 + react-dom@19.2.8(react@19.2.8):
5834 + dependencies:
5835 + react: 19.2.8
5836 + scheduler: 0.27.0
5837 +
5838 + react-is@16.13.1: {}
5839 +
5840 + react@19.2.8: {}
5841 +
5842 + readable-stream@3.6.2:
5843 + dependencies:
5844 + inherits: 2.0.4
5845 + string_decoder: 1.3.0
5846 + util-deprecate: 1.0.2
5847 +
5848 + real-require@0.2.0: {}
5849 +
5850 + redis-errors@1.2.0: {}
5851 +
5852 + redis-parser@3.0.0:
5853 + dependencies:
5854 + redis-errors: 1.2.0
5855 +
5856 + reflect.getprototypeof@1.0.10:
5857 + dependencies:
5858 + call-bind: 1.0.9
5859 + define-properties: 1.2.1
5860 + es-abstract: 1.24.2
5861 + es-errors: 1.3.0
5862 + es-object-atoms: 1.1.2
5863 + get-intrinsic: 1.3.0
5864 + get-proto: 1.0.1
5865 + which-builtin-type: 1.2.1
5866 +
5867 + regexp.prototype.flags@1.5.4:
5868 + dependencies:
5869 + call-bind: 1.0.9
5870 + define-properties: 1.2.1
5871 + es-errors: 1.3.0
5872 + get-proto: 1.0.1
5873 + gopd: 1.2.0
5874 + set-function-name: 2.0.2
5875 +
5876 + require-from-string@2.0.2: {}
5877 +
5878 + resolve-from@4.0.0: {}
5879 +
5880 + resolve-pkg-maps@1.0.0: {}
5881 +
5882 + resolve@2.0.0-next.7:
5883 + dependencies:
5884 + es-errors: 1.3.0
5885 + is-core-module: 2.16.2
5886 + node-exports-info: 1.6.2
5887 + object-keys: 1.1.1
5888 + path-parse: 1.0.7
5889 + supports-preserve-symlinks-flag: 1.0.0
5890 +
5891 + ret@0.5.0: {}
5892 +
5893 + reusify@1.1.0: {}
5894 +
5895 + rfdc@1.4.1: {}
5896 +
5897 + rollup@4.63.1:
5898 + dependencies:
5899 + '@types/estree': 1.0.9
5900 + optionalDependencies:
5901 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1
5902 + '@rollup/rollup-android-arm-eabi': 4.63.1
5903 + '@rollup/rollup-android-arm64': 4.63.1
5904 + '@rollup/rollup-darwin-arm64': 4.63.1
5905 + '@rollup/rollup-darwin-x64': 4.63.1
5906 + '@rollup/rollup-freebsd-arm64': 4.63.1
5907 + '@rollup/rollup-freebsd-x64': 4.63.1
5908 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1
5909 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1
5910 + '@rollup/rollup-linux-arm64-gnu': 4.63.1
5911 + '@rollup/rollup-linux-arm64-musl': 4.63.1
5912 + '@rollup/rollup-linux-loong64-gnu': 4.63.1
5913 + '@rollup/rollup-linux-loong64-musl': 4.63.1
5914 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1
5915 + '@rollup/rollup-linux-ppc64-musl': 4.63.1
5916 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1
5917 + '@rollup/rollup-linux-riscv64-musl': 4.63.1
5918 + '@rollup/rollup-linux-s390x-gnu': 4.63.1
5919 + '@rollup/rollup-linux-x64-gnu': 4.63.1
5920 + '@rollup/rollup-linux-x64-musl': 4.63.1
5921 + '@rollup/rollup-openbsd-x64': 4.63.1
5922 + '@rollup/rollup-openharmony-arm64': 4.63.1
5923 + '@rollup/rollup-win32-arm64-msvc': 4.63.1
5924 + '@rollup/rollup-win32-ia32-msvc': 4.63.1
5925 + '@rollup/rollup-win32-x64-gnu': 4.63.1
5926 + '@rollup/rollup-win32-x64-msvc': 4.63.1
5927 + fsevents: 2.3.3
5928 +
5929 + run-parallel@1.2.0:
5930 + dependencies:
5931 + queue-microtask: 1.2.3
5932 +
5933 + safe-array-concat@1.1.4:
5934 + dependencies:
5935 + call-bind: 1.0.9
5936 + call-bound: 1.0.4
5937 + get-intrinsic: 1.3.0
5938 + has-symbols: 1.1.0
5939 + isarray: 2.0.5
5940 +
5941 + safe-buffer@5.2.1: {}
5942 +
5943 + safe-push-apply@1.0.0:
5944 + dependencies:
5945 + es-errors: 1.3.0
5946 + isarray: 2.0.5
5947 +
5948 + safe-regex-test@1.1.0:
5949 + dependencies:
5950 + call-bound: 1.0.4
5951 + es-errors: 1.3.0
5952 + is-regex: 1.2.1
5953 +
5954 + safe-regex2@5.1.1:
5955 + dependencies:
5956 + ret: 0.5.0
5957 +
5958 + safe-stable-stringify@2.5.0: {}
5959 +
5960 + safer-buffer@2.1.2: {}
5961 +
5962 + scheduler@0.27.0: {}
5963 +
5964 + secure-json-parse@4.1.0: {}
5965 +
5966 + semver@6.3.1: {}
5967 +
5968 + semver@7.8.5: {}
5969 +
5970 + set-cookie-parser@2.7.2: {}
5971 +
5972 + set-function-length@1.2.2:
5973 + dependencies:
5974 + define-data-property: 1.1.4
5975 + es-errors: 1.3.0
5976 + function-bind: 1.1.2
5977 + get-intrinsic: 1.3.0
5978 + gopd: 1.2.0
5979 + has-property-descriptors: 1.0.2
5980 +
5981 + set-function-name@2.0.2:
5982 + dependencies:
5983 + define-data-property: 1.1.4
5984 + es-errors: 1.3.0
5985 + functions-have-names: 1.2.3
5986 + has-property-descriptors: 1.0.2
5987 +
5988 + set-proto@1.0.0:
5989 + dependencies:
5990 + dunder-proto: 1.0.1
5991 + es-errors: 1.3.0
5992 + es-object-atoms: 1.1.2
5993 +
5994 + sharp@0.35.4(@types/node@24.13.3):
5995 + dependencies:
5996 + '@img/colour': 1.1.0
5997 + detect-libc: 2.1.2
5998 + semver: 7.8.5
5999 + optionalDependencies:
6000 + '@img/sharp-darwin-arm64': 0.35.4
6001 + '@img/sharp-darwin-x64': 0.35.4
6002 + '@img/sharp-freebsd-wasm32': 0.35.4
6003 + '@img/sharp-libvips-darwin-arm64': 1.3.3
6004 + '@img/sharp-libvips-darwin-x64': 1.3.3
6005 + '@img/sharp-libvips-linux-arm': 1.3.3
6006 + '@img/sharp-libvips-linux-arm64': 1.3.3
6007 + '@img/sharp-libvips-linux-ppc64': 1.3.3
6008 + '@img/sharp-libvips-linux-riscv64': 1.3.3
6009 + '@img/sharp-libvips-linux-s390x': 1.3.3
6010 + '@img/sharp-libvips-linux-x64': 1.3.3
6011 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
6012 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
6013 + '@img/sharp-linux-arm': 0.35.4
6014 + '@img/sharp-linux-arm64': 0.35.4
6015 + '@img/sharp-linux-ppc64': 0.35.4
6016 + '@img/sharp-linux-riscv64': 0.35.4
6017 + '@img/sharp-linux-s390x': 0.35.4
6018 + '@img/sharp-linux-x64': 0.35.4
6019 + '@img/sharp-linuxmusl-arm64': 0.35.4
6020 + '@img/sharp-linuxmusl-x64': 0.35.4
6021 + '@img/sharp-webcontainers-wasm32': 0.35.4
6022 + '@img/sharp-win32-arm64': 0.35.4
6023 + '@img/sharp-win32-ia32': 0.35.4
6024 + '@img/sharp-win32-x64': 0.35.4
6025 + '@types/node': 24.13.3
6026 + optional: true
6027 +
6028 + shebang-command@2.0.0:
6029 + dependencies:
6030 + shebang-regex: 3.0.0
6031 +
6032 + shebang-regex@3.0.0: {}
6033 +
6034 + side-channel-list@1.0.1:
6035 + dependencies:
6036 + es-errors: 1.3.0
6037 + object-inspect: 1.13.4
6038 +
6039 + side-channel-map@1.0.1:
6040 + dependencies:
6041 + call-bound: 1.0.4
6042 + es-errors: 1.3.0
6043 + get-intrinsic: 1.3.0
6044 + object-inspect: 1.13.4
6045 +
6046 + side-channel-weakmap@1.0.2:
6047 + dependencies:
6048 + call-bound: 1.0.4
6049 + es-errors: 1.3.0
6050 + get-intrinsic: 1.3.0
6051 + object-inspect: 1.13.4
6052 + side-channel-map: 1.0.1
6053 +
6054 + side-channel@1.1.1:
6055 + dependencies:
6056 + es-errors: 1.3.0
6057 + object-inspect: 1.13.4
6058 + side-channel-list: 1.0.1
6059 + side-channel-map: 1.0.1
6060 + side-channel-weakmap: 1.0.2
6061 +
6062 + siginfo@2.0.0: {}
6063 +
6064 + sonic-boom@4.2.1:
6065 + dependencies:
6066 + atomic-sleep: 1.0.0
6067 +
6068 + source-map-js@1.2.1: {}
6069 +
6070 + split2@4.2.0: {}
6071 +
6072 + stable-hash@0.0.5: {}
6073 +
6074 + stackback@0.0.2: {}
6075 +
6076 + standard-as-callback@2.1.0: {}
6077 +
6078 + standardwebhooks@1.1.1:
6079 + dependencies:
6080 + '@stablelib/base64': 1.0.1
6081 + fast-sha256: 1.3.0
6082 +
6083 + std-env@3.10.0: {}
6084 +
6085 + stop-iteration-iterator@1.1.0:
6086 + dependencies:
6087 + es-errors: 1.3.0
6088 + internal-slot: 1.1.0
6089 +
6090 + stream-shift@1.0.3: {}
6091 +
6092 + string.prototype.includes@2.0.1:
6093 + dependencies:
6094 + call-bind: 1.0.9
6095 + define-properties: 1.2.1
6096 + es-abstract: 1.24.2
6097 +
6098 + string.prototype.matchall@4.1.0:
6099 + dependencies:
6100 + call-bind: 1.0.9
6101 + call-bound: 1.0.4
6102 + define-properties: 1.2.1
6103 + es-abstract: 1.24.2
6104 + es-errors: 1.3.0
6105 + es-object-atoms: 1.1.2
6106 + get-intrinsic: 1.3.0
6107 + gopd: 1.2.0
6108 + has-symbols: 1.1.0
6109 + internal-slot: 1.1.0
6110 + regexp.prototype.flags: 1.5.4
6111 + set-function-name: 2.0.2
6112 + side-channel: 1.1.1
6113 +
6114 + string.prototype.repeat@1.0.0:
6115 + dependencies:
6116 + define-properties: 1.2.1
6117 + es-abstract: 1.24.2
6118 +
6119 + string.prototype.trim@1.2.11:
6120 + dependencies:
6121 + call-bind: 1.0.9
6122 + call-bound: 1.0.4
6123 + define-data-property: 1.1.4
6124 + define-properties: 1.2.1
6125 + es-abstract: 1.24.2
6126 + es-object-atoms: 1.1.2
6127 + has-property-descriptors: 1.0.2
6128 + safe-regex-test: 1.1.0
6129 +
6130 + string.prototype.trimend@1.0.10:
6131 + dependencies:
6132 + call-bind: 1.0.9
6133 + call-bound: 1.0.4
6134 + define-properties: 1.2.1
6135 + es-object-atoms: 1.1.2
6136 +
6137 + string.prototype.trimstart@1.0.8:
6138 + dependencies:
6139 + call-bind: 1.0.9
6140 + define-properties: 1.2.1
6141 + es-object-atoms: 1.1.2
6142 +
6143 + string_decoder@1.3.0:
6144 + dependencies:
6145 + safe-buffer: 5.2.1
6146 +
6147 + strip-bom@3.0.0: {}
6148 +
6149 + strip-json-comments@3.1.1: {}
6150 +
6151 + strip-json-comments@5.0.3: {}
6152 +
6153 + strip-literal@3.1.0:
6154 + dependencies:
6155 + js-tokens: 9.0.1
6156 +
6157 + strnum@2.4.2:
6158 + dependencies:
6159 + anynum: 1.0.1
6160 +
6161 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8):
6162 + dependencies:
6163 + client-only: 0.0.1
6164 + react: 19.2.8
6165 + optionalDependencies:
6166 + '@babel/core': 7.29.7
6167 +
6168 + supports-color@7.2.0:
6169 + dependencies:
6170 + has-flag: 4.0.0
6171 +
6172 + supports-preserve-symlinks-flag@1.0.0: {}
6173 +
6174 + tailwindcss@4.3.3: {}
6175 +
6176 + tapable@2.3.3: {}
6177 +
6178 + tdigest@0.1.3:
6179 + dependencies:
6180 + bintrees: 1.0.2
6181 +
6182 + thread-stream@3.2.0:
6183 + dependencies:
6184 + real-require: 0.2.0
6185 +
6186 + tinybench@2.9.0: {}
6187 +
6188 + tinyexec@0.3.2: {}
6189 +
6190 + tinyglobby@0.2.17:
6191 + dependencies:
6192 + fdir: 6.5.0(picomatch@4.0.7)
6193 + picomatch: 4.0.7
6194 +
6195 + tinypool@1.1.1: {}
6196 +
6197 + tinyrainbow@2.0.0: {}
6198 +
6199 + tinyspy@4.0.6: {}
6200 +
6201 + to-regex-range@5.0.1:
6202 + dependencies:
6203 + is-number: 7.0.0
6204 +
6205 + toad-cache@3.7.4: {}
6206 +
6207 + ts-algebra@2.0.0: {}
6208 +
6209 + ts-api-utils@2.5.0(typescript@5.9.3):
6210 + dependencies:
6211 + typescript: 5.9.3
6212 +
6213 + tsconfig-paths@3.15.0:
6214 + dependencies:
6215 + '@types/json5': 0.0.29
6216 + json5: 1.0.2
6217 + minimist: 1.2.8
6218 + strip-bom: 3.0.0
6219 +
6220 + tslib@2.8.1: {}
6221 +
6222 + tsx@4.23.13:
6223 + dependencies:
6224 + esbuild: 0.28.2
6225 + optionalDependencies:
6226 + fsevents: 2.3.3
6227 +
6228 + type-check@0.4.0:
6229 + dependencies:
6230 + prelude-ls: 1.2.1
6231 +
6232 + typed-array-buffer@1.0.3:
6233 + dependencies:
6234 + call-bound: 1.0.4
6235 + es-errors: 1.3.0
6236 + is-typed-array: 1.1.15
6237 +
6238 + typed-array-byte-length@1.0.3:
6239 + dependencies:
6240 + call-bind: 1.0.9
6241 + for-each: 0.3.5
6242 + gopd: 1.2.0
6243 + has-proto: 1.2.0
6244 + is-typed-array: 1.1.15
6245 +
6246 + typed-array-byte-offset@1.0.4:
6247 + dependencies:
6248 + available-typed-arrays: 1.0.7
6249 + call-bind: 1.0.9
6250 + for-each: 0.3.5
6251 + gopd: 1.2.0
6252 + has-proto: 1.2.0
6253 + is-typed-array: 1.1.15
6254 + reflect.getprototypeof: 1.0.10
6255 +
6256 + typed-array-length@1.0.8:
6257 + dependencies:
6258 + call-bind: 1.0.9
6259 + for-each: 0.3.5
6260 + gopd: 1.2.0
6261 + is-typed-array: 1.1.15
6262 + possible-typed-array-names: 1.1.0
6263 + reflect.getprototypeof: 1.0.10
6264 +
6265 + typescript-eslint@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
6266 + dependencies:
6267 + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
6268 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
6269 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.9.3)
6270 + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
6271 + eslint: 9.39.5(jiti@2.7.0)
6272 + typescript: 5.9.3
6273 + transitivePeerDependencies:
6274 + - supports-color
6275 +
6276 + typescript@5.9.3: {}
6277 +
6278 + unbox-primitive@1.1.0:
6279 + dependencies:
6280 + call-bound: 1.0.4
6281 + has-bigints: 1.1.0
6282 + has-symbols: 1.1.0
6283 + which-boxed-primitive: 1.1.1
6284 +
6285 + undici-types@7.18.2: {}
6286 +
6287 + undici@7.29.1: {}
6288 +
6289 + unrs-resolver@1.12.2:
6290 + dependencies:
6291 + napi-postinstall: 0.3.4
6292 + optionalDependencies:
6293 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
6294 + '@unrs/resolver-binding-android-arm64': 1.12.2
6295 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
6296 + '@unrs/resolver-binding-darwin-x64': 1.12.2
6297 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
6298 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
6299 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
6300 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
6301 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
6302 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
6303 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
6304 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
6305 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
6306 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
6307 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
6308 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
6309 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
6310 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
6311 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
6312 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
6313 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
6314 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
6315 +
6316 + update-browserslist-db@1.3.2(browserslist@4.28.9):
6317 + dependencies:
6318 + browserslist: 4.28.9
6319 + escalade: 3.2.0
6320 + picocolors: 1.1.1
6321 +
6322 + uri-js@4.4.1:
6323 + dependencies:
6324 + punycode: 2.3.1
6325 +
6326 + util-deprecate@1.0.2: {}
6327 +
6328 + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0):
6329 + dependencies:
6330 + cac: 6.7.14
6331 + debug: 4.4.3
6332 + es-module-lexer: 1.7.0
6333 + pathe: 2.0.3
6334 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
6335 + transitivePeerDependencies:
6336 + - '@types/node'
6337 + - jiti
6338 + - less
6339 + - lightningcss
6340 + - sass
6341 + - sass-embedded
6342 + - stylus
6343 + - sugarss
6344 + - supports-color
6345 + - terser
6346 + - tsx
6347 + - yaml
6348 +
6349 + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0):
6350 + dependencies:
6351 + esbuild: 0.28.2
6352 + fdir: 6.5.0(picomatch@4.0.7)
6353 + picomatch: 4.0.7
6354 + postcss: 8.5.28
6355 + rollup: 4.63.1
6356 + tinyglobby: 0.2.17
6357 + optionalDependencies:
6358 + '@types/node': 24.13.3
6359 + fsevents: 2.3.3
6360 + jiti: 2.7.0
6361 + lightningcss: 1.32.0
6362 + tsx: 4.23.13
6363 + yaml: 2.9.0
6364 +
6365 + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0):
6366 + dependencies:
6367 + '@types/chai': 5.2.3
6368 + '@vitest/expect': 3.2.7
6369 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0))
6370 + '@vitest/pretty-format': 3.2.7
6371 + '@vitest/runner': 3.2.7
6372 + '@vitest/snapshot': 3.2.7
6373 + '@vitest/spy': 3.2.7
6374 + '@vitest/utils': 3.2.7
6375 + chai: 5.3.3
6376 + debug: 4.4.3
6377 + expect-type: 1.4.0
6378 + magic-string: 0.30.21
6379 + pathe: 2.0.3
6380 + picomatch: 4.0.7
6381 + std-env: 3.10.0
6382 + tinybench: 2.9.0
6383 + tinyexec: 0.3.2
6384 + tinyglobby: 0.2.17
6385 + tinypool: 1.1.1
6386 + tinyrainbow: 2.0.0
6387 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
6388 + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)
6389 + why-is-node-running: 2.3.0
6390 + optionalDependencies:
6391 + '@types/node': 24.13.3
6392 + transitivePeerDependencies:
6393 + - jiti
6394 + - less
6395 + - lightningcss
6396 + - msw
6397 + - sass
6398 + - sass-embedded
6399 + - stylus
6400 + - sugarss
6401 + - supports-color
6402 + - terser
6403 + - tsx
6404 + - yaml
6405 +
6406 + whatwg-encoding@3.1.1:
6407 + dependencies:
6408 + iconv-lite: 0.6.3
6409 +
6410 + whatwg-mimetype@4.0.0: {}
6411 +
6412 + which-boxed-primitive@1.1.1:
6413 + dependencies:
6414 + is-bigint: 1.1.0
6415 + is-boolean-object: 1.2.2
6416 + is-number-object: 1.1.1
6417 + is-string: 1.1.1
6418 + is-symbol: 1.1.1
6419 +
6420 + which-builtin-type@1.2.1:
6421 + dependencies:
6422 + call-bound: 1.0.4
6423 + function.prototype.name: 1.2.0
6424 + has-tostringtag: 1.0.2
6425 + is-async-function: 2.1.1
6426 + is-date-object: 1.1.0
6427 + is-finalizationregistry: 1.1.1
6428 + is-generator-function: 1.1.2
6429 + is-regex: 1.2.1
6430 + is-weakref: 1.1.1
6431 + isarray: 2.0.5
6432 + which-boxed-primitive: 1.1.1
6433 + which-collection: 1.0.2
6434 + which-typed-array: 1.1.22
6435 +
6436 + which-collection@1.0.2:
6437 + dependencies:
6438 + is-map: 2.0.3
6439 + is-set: 2.0.3
6440 + is-weakmap: 2.0.2
6441 + is-weakset: 2.0.4
6442 +
6443 + which-typed-array@1.1.22:
6444 + dependencies:
6445 + available-typed-arrays: 1.0.7
6446 + call-bind: 1.0.9
6447 + call-bound: 1.0.4
6448 + for-each: 0.3.5
6449 + get-proto: 1.0.1
6450 + gopd: 1.2.0
6451 + has-tostringtag: 1.0.2
6452 +
6453 + which@2.0.2:
6454 + dependencies:
6455 + isexe: 2.0.0
6456 +
6457 + why-is-node-running@2.3.0:
6458 + dependencies:
6459 + siginfo: 2.0.0
6460 + stackback: 0.0.2
6461 +
6462 + word-wrap@1.2.5: {}
6463 +
6464 + wrappy@1.0.2: {}
6465 +
6466 + ws@8.21.3: {}
6467 +
6468 + xml-naming@0.3.0: {}
6469 +
6470 + xtend@4.0.2: {}
6471 +
6472 + yallist@3.1.1: {}
6473 +
6474 + yaml@2.9.0: {}
6475 +
6476 + yocto-queue@0.1.0: {}
6477 +
6478 + zod-validation-error@4.0.2(zod@4.5.4):
6479 + dependencies:
6480 + zod: 4.5.4
6481 +
6482 + zod@4.5.4: {}
added pnpm-workspace.yaml +8 −0
@@ -0,0 +1,8 @@
1 +packages:
2 + - "apps/*"
3 + - "packages/*"
4 +onlyBuiltDependencies:
5 + - esbuild
6 + - sharp
7 + - '@tailwindcss/oxide'
8 + - unrs-resolver
added tests/fixtures/atom.xml +4 −0
@@ -0,0 +1,4 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<feed xmlns="http://www.w3.org/2005/Atom"><title>Release notes from tool</title><link rel="alternate" href="https://github.com/acme/tool/releases"/>
3 +<entry><id>tag:acme.com,2026:rel-2.1.0</id><updated>2026-09-08T08:00:00Z</updated><link rel="alternate" type="text/html" href="https://github.com/acme/tool/releases/tag/v2.1.0"/><title>v2.1.0</title><content type="html">&lt;p&gt;Adds streaming.&lt;/p&gt;</content><author><name>acme-bot</name></author></entry>
4 +</feed>
added tests/fixtures/kev.json +3 −0
@@ -0,0 +1,3 @@
1 +{"title":"CISA Catalog of Known Exploited Vulnerabilities","count":2,"vulnerabilities":[
2 + {"cveID":"CVE-2026-0001","vendorProject":"Acme","product":"Router","vulnerabilityName":"Acme Router RCE","dateAdded":"2026-09-08","shortDescription":"Acme Router contains an RCE vulnerability.","knownRansomwareCampaignUse":"Known","dueDate":"2026-09-29"},
3 + {"cveID":"CVE-2026-0002","vendorProject":"Beta","product":"Portal","vulnerabilityName":"Beta Portal Auth Bypass","dateAdded":"2026-09-07","shortDescription":"Beta Portal allows authentication bypass.","knownRansomwareCampaignUse":"Unknown","dueDate":"2026-09-28"}]}
added tests/fixtures/rss-after.xml +5 −0
@@ -0,0 +1,5 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<rss version="2.0"><channel><title>Acme Blog</title><link>https://acme.com/blog</link>
3 +<item><title>Acme cuts API prices by 20%</title><link>https://acme.com/blog/pricing</link><guid isPermaLink="false">post-3</guid><pubDate>Tue, 08 Sep 2026 09:30:00 GMT</pubDate><description>Input pricing moves from $10 to $8 per million tokens effective today.</description></item>
4 +<item><title>Second post</title><link>https://acme.com/blog/second</link><guid isPermaLink="false">post-2</guid><pubDate>Mon, 07 Sep 2026 10:00:00 GMT</pubDate><description><![CDATA[<p>Body of second post with <b>bold</b> text.</p>]]></description></item>
5 +</channel></rss>
added tests/fixtures/rss-before.xml +5 −0
@@ -0,0 +1,5 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<rss version="2.0"><channel><title>Acme Blog</title><link>https://acme.com/blog</link>
3 +<item><title>Second post</title><link>https://acme.com/blog/second?utm_source=rss</link><guid isPermaLink="false">post-2</guid><pubDate>Mon, 07 Sep 2026 10:00:00 GMT</pubDate><description><![CDATA[<p>Body of second post with <b>bold</b> text.</p>]]></description></item>
4 +<item><title>First post</title><link>https://acme.com/blog/first</link><guid isPermaLink="false">post-1</guid><pubDate>Sun, 06 Sep 2026 09:00:00 GMT</pubDate><description>Body of first post.</description></item>
5 +</channel></rss>
added tests/fixtures/sitemap.xml +6 −0
@@ -0,0 +1,6 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
3 +<url><loc>https://acme.com/news/launch</loc><lastmod>2026-09-08</lastmod><news:news><news:publication><news:name>Acme</news:name></news:publication><news:publication_date>2026-09-08T09:00:00Z</news:publication_date><news:title>Acme launches Widget 2</news:title></news:news></url>
4 +<url><loc>https://acme.com/pricing</loc><lastmod>2026-08-01</lastmod></url>
5 +<url><loc>https://acme.com/about</loc></url>
6 +</urlset>
added tests/fixtures/statuspage.json +5 −0
@@ -0,0 +1,5 @@
1 +{"page":{"id":"p1","name":"Acme Status","url":"https://status.acme.com","updated_at":"2026-09-08T11:00:00Z"},
2 + "status":{"indicator":"major","description":"Partial System Outage"},
3 + "components":[{"id":"c1","name":"API","status":"operational","group":false,"updated_at":"2026-09-01T00:00:00Z"},{"id":"c2","name":"Dashboard","status":"degraded_performance","group":false,"updated_at":"2026-09-08T10:50:00Z"}],
4 + "incidents":[{"id":"inc1","name":"Elevated error rates","status":"investigating","impact":"major","shortlink":"https://stspg.io/inc1","created_at":"2026-09-08T10:45:00Z","updated_at":"2026-09-08T10:55:00Z","incident_updates":[{"body":"We are investigating elevated error rates.","status":"investigating","created_at":"2026-09-08T10:45:00Z"}]}],
5 + "scheduled_maintenances":[{"id":"m1","name":"Database upgrade","status":"scheduled","impact":"maintenance","shortlink":"https://stspg.io/m1","created_at":"2026-09-07T10:00:00Z","updated_at":"2026-09-07T10:00:00Z","incident_updates":[{"body":"Scheduled for Sunday.","status":"scheduled","created_at":"2026-09-07T10:00:00Z"}]}]}
added tsconfig.base.json +17 −0
@@ -0,0 +1,17 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "module": "ESNext",
5 + "moduleResolution": "Bundler",
6 + "lib": ["ES2023", "DOM"],
7 + "strict": true,
8 + "esModuleInterop": true,
9 + "skipLibCheck": true,
10 + "forceConsistentCasingInFileNames": true,
11 + "resolveJsonModule": true,
12 + "isolatedModules": true,
13 + "noUncheckedIndexedAccess": false,
14 + "declaration": false,
15 + "verbatimModuleSyntax": false
16 + }
17 +}
18