From 5ce52fff4d09f42bad5993eb4b124b107697bf73 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 8 Jul 2026 11:36:57 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20scaffold=20newsfeed=20=E2=80=94=20user-?= =?UTF-8?q?controlled=20news=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-hosted feed where the user owns the ranking: per-source and signed per-interest weights, decayed by recency, via a transparent deterministic scorer. Content is sourced algorithmically (worker RSS/Atom polling) and agentically (per-user API tokens POSTing candidates to the ingest endpoint). Single-user today, multi-user by construction (every row keyed on user_id). Rust cargo workspace + Vite/React/SWC/TS SPA: - newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings) - newsfeed-core: ranking, auth primitives, ingest, data-access ports - newsfeed-data: SQLite adapters (sqlx, runtime queries) - newsfeed-api: axum REST/JSON daemon - newsfeed-worker: RSS polling + rescoring loop - web: responsive, mobile-first SPA (React Query, generated types) Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci): api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1). Deliberate deviations from house conventions (documented in CLAUDE.md/readme): - SQLite instead of Postgres; api+worker co-locate sharing one DB file. - Runtime sqlx queries instead of query! macros (SQLite dynamic typing; keeps CI database-free). Verified end-to-end: auth, token ingest, interest-weighted ranking, signals, pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP --- .cargo/config.toml | 5 + .gitea/workflows/deploy.yml | 159 + .gitignore | 22 + CLAUDE.md | 69 + Cargo.lock | 3312 +++++++++++++++++ Cargo.toml | 62 + asset/config/config.toml.tmpl | 20 + asset/config/worker.toml.tmpl | 16 + asset/firewalld/newsfeed-api.xml | 7 + asset/nginx/newsfeed.internal.conf | 53 + asset/nginx/newsfeed.public.conf | 45 + asset/systemd/newsfeed-api.service | 38 + asset/systemd/newsfeed-worker.service | 36 + asset/systemd/newsfeed.sysusers.conf | 2 + crates/newsfeed-api/Cargo.toml | 34 + crates/newsfeed-api/src/auth.rs | 98 + crates/newsfeed-api/src/config.rs | 59 + crates/newsfeed-api/src/error.rs | 81 + crates/newsfeed-api/src/main.rs | 100 + crates/newsfeed-api/src/routes/auth_routes.rs | 83 + crates/newsfeed-api/src/routes/feed.rs | 77 + crates/newsfeed-api/src/routes/health.rs | 15 + crates/newsfeed-api/src/routes/ingest.rs | 29 + crates/newsfeed-api/src/routes/interests.rs | 41 + crates/newsfeed-api/src/routes/mod.rs | 74 + crates/newsfeed-api/src/routes/sources.rs | 42 + crates/newsfeed-api/src/routes/tokens.rs | 45 + crates/newsfeed-api/src/state.rs | 24 + crates/newsfeed-core/Cargo.toml | 23 + crates/newsfeed-core/src/auth.rs | 126 + crates/newsfeed-core/src/error.rs | 36 + crates/newsfeed-core/src/ingest.rs | 121 + crates/newsfeed-core/src/lib.rs | 15 + crates/newsfeed-core/src/ports.rs | 156 + crates/newsfeed-core/src/ranking.rs | 156 + crates/newsfeed-core/src/service.rs | 131 + crates/newsfeed-data/Cargo.toml | 23 + crates/newsfeed-data/migrations/0001_init.sql | 92 + crates/newsfeed-data/src/err.rs | 24 + crates/newsfeed-data/src/lib.rs | 52 + crates/newsfeed-data/src/rows.rs | 197 + crates/newsfeed-data/src/store/interests.rs | 63 + crates/newsfeed-data/src/store/items.rs | 206 + crates/newsfeed-data/src/store/mod.rs | 33 + crates/newsfeed-data/src/store/sessions.rs | 65 + crates/newsfeed-data/src/store/sources.rs | 113 + crates/newsfeed-data/src/store/tokens.rs | 90 + crates/newsfeed-data/src/store/users.rs | 67 + crates/newsfeed-entities/Cargo.toml | 16 + crates/newsfeed-entities/src/auth.rs | 61 + crates/newsfeed-entities/src/error.rs | 30 + crates/newsfeed-entities/src/feed.rs | 54 + crates/newsfeed-entities/src/interest.rs | 45 + crates/newsfeed-entities/src/item.rs | 89 + crates/newsfeed-entities/src/lib.rs | 22 + crates/newsfeed-entities/src/source.rs | 92 + crates/newsfeed-entities/src/user.rs | 54 + crates/newsfeed-worker/Cargo.toml | 31 + crates/newsfeed-worker/src/config.rs | 55 + crates/newsfeed-worker/src/main.rs | 168 + crates/newsfeed-worker/src/sourcing/mod.rs | 3 + crates/newsfeed-worker/src/sourcing/rss.rs | 80 + readme.md | 140 + rust-toolchain.toml | 4 + script/infra-setup.sh | 213 ++ web/.prettierrc.json | 6 + web/eslint.config.js | 25 + web/index.html | 13 + web/package.json | 34 + web/pnpm-lock.yaml | 1857 +++++++++ web/src/App.tsx | 42 + web/src/api/bindings/ApiTokenInfo.ts | 11 + web/src/api/bindings/CandidateSubmission.ts | 18 + web/src/api/bindings/ContentItem.ts | 16 + web/src/api/bindings/CreateApiToken.ts | 6 + web/src/api/bindings/CreatedApiToken.ts | 15 + web/src/api/bindings/FeedPage.ts | 11 + web/src/api/bindings/FeedQuery.ts | 18 + web/src/api/bindings/Interest.ts | 15 + web/src/api/bindings/ItemState.ts | 6 + web/src/api/bindings/LoginRequest.ts | 10 + web/src/api/bindings/Me.ts | 6 + web/src/api/bindings/Media.ts | 6 + web/src/api/bindings/NewSource.ts | 11 + web/src/api/bindings/RegisterRequest.ts | 6 + web/src/api/bindings/Signal.ts | 7 + web/src/api/bindings/SignalAction.ts | 6 + web/src/api/bindings/Source.ts | 19 + web/src/api/bindings/SourceKind.ts | 6 + web/src/api/bindings/UpsertInterest.ts | 6 + web/src/api/bindings/User.ts | 7 + web/src/api/client.ts | 84 + web/src/components/FeedItem.tsx | 65 + web/src/components/Nav.tsx | 38 + web/src/index.css | 438 +++ web/src/lib/auth.tsx | 50 + web/src/main.tsx | 21 + web/src/routes/Feed.tsx | 58 + web/src/routes/Login.tsx | 95 + web/src/routes/Settings.tsx | 172 + web/src/routes/Sources.tsx | 114 + web/src/vite-env.d.ts | 9 + web/tsconfig.json | 22 + web/vite.config.ts | 20 + 104 files changed, 10963 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 asset/config/config.toml.tmpl create mode 100644 asset/config/worker.toml.tmpl create mode 100644 asset/firewalld/newsfeed-api.xml create mode 100644 asset/nginx/newsfeed.internal.conf create mode 100644 asset/nginx/newsfeed.public.conf create mode 100644 asset/systemd/newsfeed-api.service create mode 100644 asset/systemd/newsfeed-worker.service create mode 100644 asset/systemd/newsfeed.sysusers.conf create mode 100644 crates/newsfeed-api/Cargo.toml create mode 100644 crates/newsfeed-api/src/auth.rs create mode 100644 crates/newsfeed-api/src/config.rs create mode 100644 crates/newsfeed-api/src/error.rs create mode 100644 crates/newsfeed-api/src/main.rs create mode 100644 crates/newsfeed-api/src/routes/auth_routes.rs create mode 100644 crates/newsfeed-api/src/routes/feed.rs create mode 100644 crates/newsfeed-api/src/routes/health.rs create mode 100644 crates/newsfeed-api/src/routes/ingest.rs create mode 100644 crates/newsfeed-api/src/routes/interests.rs create mode 100644 crates/newsfeed-api/src/routes/mod.rs create mode 100644 crates/newsfeed-api/src/routes/sources.rs create mode 100644 crates/newsfeed-api/src/routes/tokens.rs create mode 100644 crates/newsfeed-api/src/state.rs create mode 100644 crates/newsfeed-core/Cargo.toml create mode 100644 crates/newsfeed-core/src/auth.rs create mode 100644 crates/newsfeed-core/src/error.rs create mode 100644 crates/newsfeed-core/src/ingest.rs create mode 100644 crates/newsfeed-core/src/lib.rs create mode 100644 crates/newsfeed-core/src/ports.rs create mode 100644 crates/newsfeed-core/src/ranking.rs create mode 100644 crates/newsfeed-core/src/service.rs create mode 100644 crates/newsfeed-data/Cargo.toml create mode 100644 crates/newsfeed-data/migrations/0001_init.sql create mode 100644 crates/newsfeed-data/src/err.rs create mode 100644 crates/newsfeed-data/src/lib.rs create mode 100644 crates/newsfeed-data/src/rows.rs create mode 100644 crates/newsfeed-data/src/store/interests.rs create mode 100644 crates/newsfeed-data/src/store/items.rs create mode 100644 crates/newsfeed-data/src/store/mod.rs create mode 100644 crates/newsfeed-data/src/store/sessions.rs create mode 100644 crates/newsfeed-data/src/store/sources.rs create mode 100644 crates/newsfeed-data/src/store/tokens.rs create mode 100644 crates/newsfeed-data/src/store/users.rs create mode 100644 crates/newsfeed-entities/Cargo.toml create mode 100644 crates/newsfeed-entities/src/auth.rs create mode 100644 crates/newsfeed-entities/src/error.rs create mode 100644 crates/newsfeed-entities/src/feed.rs create mode 100644 crates/newsfeed-entities/src/interest.rs create mode 100644 crates/newsfeed-entities/src/item.rs create mode 100644 crates/newsfeed-entities/src/lib.rs create mode 100644 crates/newsfeed-entities/src/source.rs create mode 100644 crates/newsfeed-entities/src/user.rs create mode 100644 crates/newsfeed-worker/Cargo.toml create mode 100644 crates/newsfeed-worker/src/config.rs create mode 100644 crates/newsfeed-worker/src/main.rs create mode 100644 crates/newsfeed-worker/src/sourcing/mod.rs create mode 100644 crates/newsfeed-worker/src/sourcing/rss.rs create mode 100644 readme.md create mode 100644 rust-toolchain.toml create mode 100755 script/infra-setup.sh create mode 100644 web/.prettierrc.json create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/App.tsx create mode 100644 web/src/api/bindings/ApiTokenInfo.ts create mode 100644 web/src/api/bindings/CandidateSubmission.ts create mode 100644 web/src/api/bindings/ContentItem.ts create mode 100644 web/src/api/bindings/CreateApiToken.ts create mode 100644 web/src/api/bindings/CreatedApiToken.ts create mode 100644 web/src/api/bindings/FeedPage.ts create mode 100644 web/src/api/bindings/FeedQuery.ts create mode 100644 web/src/api/bindings/Interest.ts create mode 100644 web/src/api/bindings/ItemState.ts create mode 100644 web/src/api/bindings/LoginRequest.ts create mode 100644 web/src/api/bindings/Me.ts create mode 100644 web/src/api/bindings/Media.ts create mode 100644 web/src/api/bindings/NewSource.ts create mode 100644 web/src/api/bindings/RegisterRequest.ts create mode 100644 web/src/api/bindings/Signal.ts create mode 100644 web/src/api/bindings/SignalAction.ts create mode 100644 web/src/api/bindings/Source.ts create mode 100644 web/src/api/bindings/SourceKind.ts create mode 100644 web/src/api/bindings/UpsertInterest.ts create mode 100644 web/src/api/bindings/User.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/components/FeedItem.tsx create mode 100644 web/src/components/Nav.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/auth.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/routes/Feed.tsx create mode 100644 web/src/routes/Login.tsx create mode 100644 web/src/routes/Settings.tsx create mode 100644 web/src/routes/Sources.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e3498ba --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# Generated TypeScript bindings from `newsfeed-entities` land directly in the web app. +# Regenerate with: cargo test -p newsfeed-entities +# `relative = true` resolves the path against this config file's parent (the repo root). +[env] +TS_RS_EXPORT_DIR = { value = "web/src/api/bindings", relative = true } diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..15c0b9d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,159 @@ +# CI-driven deploy for newsfeed (architecture deployment-gitea-actions.md). +# +# The workflow is the source of infra truth — hosts/ports/paths live here, not in a +# manifest. Build-and-rsync model: build static musl binaries + the SPA bundle, then +# rsync straight to the targets as the scoped `gitea_ci` user. +# +# api + worker -> slartibartfast.kosherinata.internal (share one SQLite file) +# web (SPA) -> oolon.kosherinata.internal (nginx serves + proxies /v1) +# +# One-time host prep (gitea_ci user, sudoers, service account, dirs, cert, vhost) is done +# by script/infra-setup.sh. Only secret required here: RSYNC_SSH_KEY (the runner's private +# key). newsfeed has no app secrets — SQLite has no password and tokens are hashed at rest. + +name: deploy +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +env: + API_HOST: slartibartfast.kosherinata.internal + WEB_HOST: oolon.kosherinata.internal + API_PORT: "8081" + TARGET: x86_64-unknown-linux-musl + SSH_OPTS: -o StrictHostKeyChecking=accept-new + +jobs: + build: + # Toolchain runner: rustup with the musl target + musl-gcc, node+pnpm baked into the + # image (deployment-gitea-actions.md §5). Adjust the label to a registered runner. + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + + # --- lint/test gate: a broken commit never deploys --- + - name: fmt + run: cargo fmt --all --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: test + run: cargo test --workspace + + # --- build static binaries --- + - name: build binaries + run: | + rustup target add "$TARGET" + cargo build --release --target "$TARGET" -p newsfeed-api -p newsfeed-worker + mkdir -p dist/bin + cp "target/$TARGET/release/newsfeed-api" dist/bin/ + cp "target/$TARGET/release/newsfeed-worker" dist/bin/ + + # --- build SPA bundle --- + - name: build web + working-directory: web + run: | + corepack enable + pnpm install --frozen-lockfile + pnpm build + + - uses: actions/upload-artifact@v3 + with: + name: binaries + path: dist/bin/ + - uses: actions/upload-artifact@v3 + with: + name: web + path: web/dist/ + + deploy-api: + needs: build + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: + name: binaries + path: bin + - name: write ssh key + run: | + install -d -m 0700 ~/.ssh + echo "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_deploy + chmod 0600 ~/.ssh/id_deploy + - name: reachability check + run: ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" hostname -f + + - name: rsync binaries + run: | + chmod +x bin/* + for b in newsfeed-api newsfeed-worker; do + rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \ + --rsync-path='sudo rsync' --chown=root:root --chmod=0755 --mkpath \ + "bin/$b" gitea_ci@"$API_HOST":/usr/local/bin/"$b" + done + + - name: rsync config + run: | + rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \ + --rsync-path='sudo rsync' --chown=root:newsfeed --chmod=0640 --mkpath \ + asset/config/config.toml.tmpl gitea_ci@"$API_HOST":/etc/newsfeed/config.toml + rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \ + --rsync-path='sudo rsync' --chown=root:newsfeed --chmod=0640 --mkpath \ + asset/config/worker.toml.tmpl gitea_ci@"$API_HOST":/etc/newsfeed/worker.toml + + - name: apply system state + run: | + ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" ' + sudo restorecon -R /usr/local/bin/newsfeed-api /usr/local/bin/newsfeed-worker /etc/newsfeed /var/lib/newsfeed + sudo systemctl daemon-reload + sudo systemctl restart newsfeed-api.service + sudo systemctl restart newsfeed-worker.service + ' + + - name: health probe + run: | + for i in $(seq 1 10); do + code=$(curl -fsS -o /dev/null -w '%{http_code}' "http://$API_HOST:$API_PORT/health") && [ "$code" = 200 ] && exit 0 + sleep 2 + done + echo "api did not become healthy" >&2; exit 1 + + - name: capture journal + if: always() + run: | + ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" \ + 'journalctl -u newsfeed-api.service -u newsfeed-worker.service -n 80 --no-pager' || true + + deploy-web: + needs: build + runs-on: fedora-43 + steps: + - uses: actions/download-artifact@v3 + with: + name: web + path: dist + - name: write ssh key + run: | + install -d -m 0700 ~/.ssh + echo "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_deploy + chmod 0600 ~/.ssh/id_deploy + - name: reachability check + run: ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$WEB_HOST" hostname -f + + - name: rsync SPA + run: | + rsync -az --delete -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \ + --rsync-path='sudo rsync' --chown=nginx:nginx --mkpath \ + dist/ gitea_ci@"$WEB_HOST":/var/www/newsfeed/ + + - name: relabel + reload nginx + run: | + ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$WEB_HOST" ' + sudo restorecon -R /var/www/newsfeed + sudo nginx -t + sudo systemctl reload nginx.service + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ebb638 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# rust +/target +**/*.rs.bk + +# local dev database (sqlx offline cache in .sqlx/ IS committed) +*.db +*.db-shm +*.db-wal +/data + +# env / secrets — never commit rendered config +.env +*.local + +# node +node_modules +web/dist +web/.vite + +# editor / os +.DS_Store +*.swp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72cb7d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md — newsfeed + +Agent-facing notes for working in this repo. Read the workspace-wide conventions in +`~/git/architecture/*.md` first; this file records only what's specific to newsfeed, +especially the deliberate deviations. + +## What this is + +A self-hosted, user-controlled news feed. The core idea: the user owns the ranking. Never +introduce an implicit/opaque signal that overrides the user's explicit weights. Signals +(view/click/save/dismiss) may *inform* future ranking but must remain subordinate to the +per-source and per-interest weights the user sets. + +## Layout & boundaries (strict) + +- `newsfeed-entities` — types/DTOs only, no I/O. Wire DTOs derive `ts_rs::TS` with + `#[ts(export)]`; `cargo test -p newsfeed-entities` regenerates `web/src/api/bindings/` + (path set by `.cargo/config.toml`'s `TS_RS_EXPORT_DIR`). Don't hand-edit bindings. +- `newsfeed-core` — business logic + data-access **ports** (traits). Pure where possible + (ranking, auth hashing). No DB or network calls. +- `newsfeed-data` — SQLite **adapters** implementing the core ports. +- `newsfeed-api` / `newsfeed-worker` — thin binaries; wire config/logging/signals and the + concrete store. No business logic that could live in a library crate. + +New types → entities. New logic → core. New I/O → data. Add a port to +`core::ports`, implement it in `data::store`. + +## Deliberate deviations from `generic.md` + +1. **SQLite instead of Postgres (§5).** Explicit project choice. Because SQLite is + single-file/single-host, **api and worker co-locate** and share `/var/lib/newsfeed/newsfeed.db`. + There is no central DB cluster, no mTLS DB auth, no `pg_ident` mapping. The worker does + in-process scheduling — the `FOR UPDATE SKIP LOCKED` guidance does not apply. +2. **Runtime sqlx queries, not `query!` macros (§5).** SQLite's dynamic typing makes + compile-time checking low-value and forces a live DB or fiddly offline cache into CI. + We use `sqlx::query`/`query_as` with `FromRow` row structs (see `data/src/rows.rs`) and + map to entities explicitly. No `.sqlx/` cache; CI builds need no database. + +Both are documented at their site (`data/src/lib.rs` header, `readme.md`) — keep them in +sync if you change the approach. + +## Datastore conventions + +TEXT UUIDs (hyphenated), RFC3339 timestamps, INTEGER booleans, JSON-in-TEXT for small +collections (`tags`, `media`). Migrations are immutable once committed (§5): add a new +`crates/newsfeed-data/migrations/NNNN_*.sql`, never edit a landed one. `RETURNING` and +`ON CONFLICT` are used, so the target needs SQLite ≥ 3.35. + +## Auth model + +- Humans: Argon2id password hash + opaque session cookie (`nf_session`), hash stored. +- Producers: per-user bearer API tokens (`nf__`), SHA-256 hashed at rest, + shown once. Ingest is attributed to the token's owner. +Crypto/token logic lives in `core::auth`; the flow orchestration in `core::service`. + +## Deploy topology + +api + worker → `slartibartfast.kosherinata.internal`; SPA → `oolon.kosherinata.internal` +(nginx serves + proxies `/v1` to the API over the mesh; TLS terminates at oolon). API +port 8081, plain HTTP behind firewalld. See `.gitea/workflows/deploy.yml` (infra truth) +and `script/infra-setup.sh` (one-time host prep). `Type=exec`, not `notify`, because axum +doesn't `sd_notify`. + +## Before you commit + +- `cargo fmt --all` · `cargo clippy --workspace --all-targets -- -D warnings` · `cargo test --workspace` +- `pnpm --dir web build` (tsc typecheck + vite) · `pnpm --dir web lint` +- If you touched an entity DTO, regenerate bindings and include them in the commit. +- Conventional Commits; scope = crate/area (`feat(api):`, `fix(core):`, …). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..69e1f06 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3312 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "feed-rs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" +dependencies = [ + "chrono", + "mediatype", + "quick-xml", + "regex", + "serde", + "serde_json", + "siphasher", + "url", + "uuid", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" +dependencies = [ + "serde", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "newsfeed-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "figment", + "newsfeed-core", + "newsfeed-data", + "newsfeed-entities", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "newsfeed-core" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "base64", + "chrono", + "newsfeed-entities", + "rand 0.8.6", + "serde", + "sha2", + "thiserror", + "uuid", +] + +[[package]] +name = "newsfeed-data" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "newsfeed-core", + "newsfeed-entities", + "serde_json", + "sqlx", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "newsfeed-entities" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror", + "ts-rs", + "uuid", +] + +[[package]] +name = "newsfeed-worker" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "feed-rs", + "figment", + "newsfeed-core", + "newsfeed-data", + "newsfeed-entities", + "reqwest", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "chrono", + "lazy_static", + "serde_json", + "thiserror", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5db5859 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,62 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "GPL-3.0-or-later" +authors = ["Rob Thijssen "] + +[workspace.dependencies] +# internal crates +newsfeed-entities = { path = "crates/newsfeed-entities", version = "=0.1.0" } +newsfeed-core = { path = "crates/newsfeed-core", version = "=0.1.0" } +newsfeed-data = { path = "crates/newsfeed-data", version = "=0.1.0" } + +# async runtime + web +tokio = { version = "1", features = ["full"] } +axum = { version = "0.8", features = ["macros", "ws"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"] } + +# data +sqlx = { version = "0.8", default-features = false, features = [ + "sqlite", + "runtime-tokio-rustls", + "macros", + "migrate", + "chrono", + "uuid", +] } + +# serde / types +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +ts-rs = { version = "10", features = ["chrono-impl", "uuid-impl", "serde-json-impl"] } + +# traits +async-trait = "0.1" + +# errors / logging / config +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +figment = { version = "0.10", features = ["toml", "env"] } + +# auth / crypto +argon2 = "0.5" +rand = "0.8" +sha2 = "0.10" +base64 = "0.22" + +# worker: feed sourcing +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] } +feed-rs = "2" + +# cli niceties +clap = { version = "4", features = ["derive", "env"] } diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl new file mode 100644 index 0000000..66e4b1d --- /dev/null +++ b/asset/config/config.toml.tmpl @@ -0,0 +1,20 @@ +# newsfeed-api configuration (production). Rendered to /etc/newsfeed/config.toml. +# +# This file contains no secrets — newsfeed uses SQLite (no DB password), and session +# cookies / API tokens are random values hashed at rest (no signing key). It is therefore +# committed as concrete values, not {{PLACEHOLDER}} templates. Env (NEWSFEED_*) still +# overrides any value at runtime. + +# Bind the mesh-routable address so the oolon proxy can reach it. firewalld +# (newsfeed-api service) bounds who may connect; TLS terminates at oolon. +bind = "0.0.0.0:8081" + +database_path = "/var/lib/newsfeed/newsfeed.db" +session_ttl_days = 30 +max_db_connections = 5 + +# Same-origin in production (oolon serves the SPA and proxies the API), so no CORS. +cors_origins = [] + +# Cookies are only sent over HTTPS (the browser talks to oolon over TLS). +cookie_secure = true diff --git a/asset/config/worker.toml.tmpl b/asset/config/worker.toml.tmpl new file mode 100644 index 0000000..5620c86 --- /dev/null +++ b/asset/config/worker.toml.tmpl @@ -0,0 +1,16 @@ +# newsfeed-worker configuration (production). Rendered to /etc/newsfeed/worker.toml. +# No secrets (see config.toml.tmpl). + +database_path = "/var/lib/newsfeed/newsfeed.db" +max_db_connections = 2 + +# Poll cadence. +tick_secs = 60 +# Don't re-poll the same RSS source more often than this. +source_min_interval_secs = 900 +# Sources polled per cycle, and items rescored per user per cycle. +batch = 20 +rescore_limit = 500 + +http_timeout_secs = 20 +user_agent = "newsfeed-worker (+https://git.lair.cafe/grenade/newsfeed)" diff --git a/asset/firewalld/newsfeed-api.xml b/asset/firewalld/newsfeed-api.xml new file mode 100644 index 0000000..2bdbe56 --- /dev/null +++ b/asset/firewalld/newsfeed-api.xml @@ -0,0 +1,7 @@ + + + newsfeed-api + newsfeed REST/JSON API. Reached over the WireGuard mesh by the oolon edge + proxy, which terminates TLS and reverse-proxies to this port. + + diff --git a/asset/nginx/newsfeed.internal.conf b/asset/nginx/newsfeed.internal.conf new file mode 100644 index 0000000..3c07638 --- /dev/null +++ b/asset/nginx/newsfeed.internal.conf @@ -0,0 +1,53 @@ +# Mesh-only vhost for newsfeed, fronted by the oolon edge proxy (kosherinata site). +# Cert: internal `lair` CA, minted by infra-setup.sh and renewed by step@newsfeed.timer +# (see architecture internal-tls.md). Serves the static SPA and reverse-proxies the API +# to the newsfeed-api daemon on slartibartfast over the WireGuard mesh. +# +# Enable with a relative symlink into sites-enabled (reverse-proxies.md §4): +# ln -sf ../sites-available/newsfeed.internal.conf /etc/nginx/sites-enabled/ + +upstream newsfeed_api { + server slartibartfast.kosherinata.internal:8081; + keepalive 16; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name newsfeed.internal; + + ssl_certificate /etc/nginx/tls/cert/newsfeed.internal.pem; + ssl_certificate_key /etc/nginx/tls/key/newsfeed.internal.pem; + ssl_protocols TLSv1.3; + ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem; + + root /var/www/newsfeed; + index index.html; + + # API + health probe → newsfeed-api on slartibartfast. + location /v1/ { + proxy_pass http://newsfeed_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # For future WebSocket streaming endpoints, add the standard Upgrade/Connection + # map here (define `$connection_upgrade` once in http context). + } + location = /health { + proxy_pass http://newsfeed_api; + } + + # Static SPA with client-side routing fallback. + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache the fingerprinted asset bundle. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/asset/nginx/newsfeed.public.conf b/asset/nginx/newsfeed.public.conf new file mode 100644 index 0000000..7851716 --- /dev/null +++ b/asset/nginx/newsfeed.public.conf @@ -0,0 +1,45 @@ +# Public (WAN) vhost for newsfeed, fronted by the oolon edge proxy. +# +# Provision the Let's Encrypt cert for rob.fyi (architecture external-tls.md — certbot, +# Cloudflare DNS-01, ECDSA) BEFORE enabling this vhost: `nginx -t` fails on a missing +# ssl_certificate, so only symlink this into sites-enabled once the cert exists +# (reverse-proxies.md §4). +# +# Mesh clients cannot use this name (the public-name hairpin gotcha, reverse-proxies.md +# §2) — they use newsfeed.internal instead. Both vhosts share the /var/www/newsfeed +# webroot and the newsfeed_api upstream defined in newsfeed.internal.conf. + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name rob.fyi; + + ssl_certificate /etc/letsencrypt/live/rob.fyi/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rob.fyi/privkey.pem; + # Classical curves for WAN clients that don't speak post-quantum yet (generic.md §11). + ssl_protocols TLSv1.2 TLSv1.3; + + root /var/www/newsfeed; + index index.html; + + location /v1/ { + proxy_pass http://newsfeed_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /health { + proxy_pass http://newsfeed_api; + } + + location / { + try_files $uri $uri/ /index.html; + } + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/asset/systemd/newsfeed-api.service b/asset/systemd/newsfeed-api.service new file mode 100644 index 0000000..5606086 --- /dev/null +++ b/asset/systemd/newsfeed-api.service @@ -0,0 +1,38 @@ +[Unit] +Description=newsfeed REST/JSON API +Documentation=https://git.lair.cafe/grenade/newsfeed +After=network-online.target +Wants=network-online.target + +[Service] +# Type=exec (not notify): axum does not sd_notify READY=1, so notify would block until +# TimeoutStartSec. exec treats the unit as started once the binary execs successfully. +Type=exec +User=newsfeed +Group=newsfeed +ExecStart=/usr/local/bin/newsfeed-api --config /etc/newsfeed/config.toml +Restart=on-failure +RestartSec=2 + +# Hardening (architecture generic.md §8). Relax individually only if a feature needs it. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +# The SQLite database and WAL live here; shared read/write with newsfeed-worker. +ReadWritePaths=/var/lib/newsfeed + +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/newsfeed-worker.service b/asset/systemd/newsfeed-worker.service new file mode 100644 index 0000000..db9f867 --- /dev/null +++ b/asset/systemd/newsfeed-worker.service @@ -0,0 +1,36 @@ +[Unit] +Description=newsfeed content-sourcing worker (RSS/Atom polling + rescoring) +Documentation=https://git.lair.cafe/grenade/newsfeed +After=network-online.target newsfeed-api.service +Wants=network-online.target + +[Service] +Type=exec +User=newsfeed +Group=newsfeed +ExecStart=/usr/local/bin/newsfeed-worker --config /etc/newsfeed/worker.toml +Restart=on-failure +RestartSec=5 + +# Hardening (architecture generic.md §8). +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +# Same database file as the api (co-located: SQLite is single-host). +ReadWritePaths=/var/lib/newsfeed + +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/newsfeed.sysusers.conf b/asset/systemd/newsfeed.sysusers.conf new file mode 100644 index 0000000..e646fdd --- /dev/null +++ b/asset/systemd/newsfeed.sysusers.conf @@ -0,0 +1,2 @@ +#Type Name ID GECOS Home directory Shell +u newsfeed - "newsfeed service account" /var/lib/newsfeed /usr/sbin/nologin diff --git a/crates/newsfeed-api/Cargo.toml b/crates/newsfeed-api/Cargo.toml new file mode 100644 index 0000000..dc80036 --- /dev/null +++ b/crates/newsfeed-api/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "newsfeed-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "REST/JSON API daemon for newsfeed (axum)." + +[[bin]] +name = "newsfeed-api" +path = "src/main.rs" + +[dependencies] +newsfeed-entities.workspace = true +newsfeed-core.workspace = true +newsfeed-data.workspace = true + +tokio.workspace = true +axum.workspace = true +tower.workspace = true +tower-http.workspace = true + +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +uuid.workspace = true + +thiserror.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +figment.workspace = true +clap.workspace = true diff --git a/crates/newsfeed-api/src/auth.rs b/crates/newsfeed-api/src/auth.rs new file mode 100644 index 0000000..acff5dc --- /dev/null +++ b/crates/newsfeed-api/src/auth.rs @@ -0,0 +1,98 @@ +//! Authentication extractors and cookie helpers. +//! +//! [`CurrentUser`] authenticates a browser via the `nf_session` cookie; [`ApiPrincipal`] +//! authenticates an agentic/algorithmic producer via a `Bearer` API token. Both resolve +//! through `newsfeed-core::service`, so the crypto and lookup live in one place. + +use axum::extract::FromRef; +use axum::extract::FromRequestParts; +use axum::http::header::{AUTHORIZATION, COOKIE}; +use axum::http::request::Parts; + +use newsfeed_core::service; +use newsfeed_entities::auth::ApiTokenInfo; +use newsfeed_entities::user::User; + +use crate::error::ApiError; +use crate::state::AppState; + +/// Name of the session cookie. +pub const SESSION_COOKIE: &str = "nf_session"; + +/// Build a `Set-Cookie` header value for a freshly minted session. +pub fn session_cookie(value: &str, ttl_days: i64, secure: bool) -> String { + let max_age = ttl_days.max(0) * 24 * 60 * 60; + let mut c = + format!("{SESSION_COOKIE}={value}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}"); + if secure { + c.push_str("; Secure"); + } + c +} + +/// Build a `Set-Cookie` header value that clears the session cookie. +pub fn clear_session_cookie(secure: bool) -> String { + let mut c = format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"); + if secure { + c.push_str("; Secure"); + } + c +} + +/// Extract the value of a named cookie from a `Cookie` header. +fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { + let header = parts.headers.get(COOKIE)?.to_str().ok()?; + header.split(';').find_map(|pair| { + let (k, v) = pair.trim().split_once('=')?; + (k == name).then_some(v) + }) +} + +/// Extract a `Bearer` token from the `Authorization` header. +fn bearer(parts: &Parts) -> Option<&str> { + let header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?; + header.strip_prefix("Bearer ").map(str::trim) +} + +/// An authenticated human user, resolved from the session cookie. +pub struct CurrentUser(pub User); + +impl FromRequestParts for CurrentUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app = AppState::from_ref(state); + let value = cookie_value(parts, SESSION_COOKIE).ok_or_else(ApiError::unauthorized)?; + let user = service::authenticate_session(app.store.as_ref(), value).await?; + Ok(CurrentUser(user)) + } +} + +/// An authenticated API-token principal (agentic/algorithmic producer). +pub struct ApiPrincipal(pub ApiTokenInfo); + +impl ApiPrincipal { + /// The user the token belongs to. + pub fn user_id(&self) -> uuid::Uuid { + self.0.user_id + } +} + +impl FromRequestParts for ApiPrincipal +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app = AppState::from_ref(state); + let token = bearer(parts).ok_or_else(ApiError::unauthorized)?; + let info = service::authenticate_token(app.store.as_ref(), token).await?; + Ok(ApiPrincipal(info)) + } +} diff --git a/crates/newsfeed-api/src/config.rs b/crates/newsfeed-api/src/config.rs new file mode 100644 index 0000000..6dab44b --- /dev/null +++ b/crates/newsfeed-api/src/config.rs @@ -0,0 +1,59 @@ +//! Layered configuration: built-in defaults → optional TOML file → environment +//! (`NEWSFEED_*`). Matches the figment-style layering in architecture `generic.md` §3. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use figment::Figment; +use figment::providers::{Env, Format, Serialized, Toml}; +use serde::{Deserialize, Serialize}; + +/// API daemon configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Address to bind the HTTP listener. TLS terminates upstream at the site nginx. + pub bind: SocketAddr, + /// Path to the SQLite database file (shared with the worker on the same host). + pub database_path: PathBuf, + /// Session lifetime in days. + pub session_ttl_days: i64, + /// Maximum SQLite connections in the pool. + pub max_db_connections: u32, + /// Allowed CORS origins. Empty means same-origin only (the production shape, where + /// nginx serves the SPA and proxies the API under one origin). + pub cors_origins: Vec, + /// Set `Secure` on the session cookie. True in production (HTTPS via nginx); can be + /// disabled for plain-HTTP local development. + pub cookie_secure: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + bind: "127.0.0.1:8081".parse().expect("valid default bind addr"), + database_path: PathBuf::from("/var/lib/newsfeed/newsfeed.db"), + session_ttl_days: 30, + max_db_connections: 5, + cors_origins: Vec::new(), + cookie_secure: true, + } + } +} + +impl Config { + /// Load configuration, layering an optional TOML file and the environment over the + /// defaults. Env vars are `NEWSFEED_` prefixed (e.g. `NEWSFEED_BIND=0.0.0.0:8081`). + pub fn load(file: Option<&Path>) -> anyhow::Result { + let mut fig = Figment::from(Serialized::defaults(Config::default())); + if let Some(path) = file { + fig = fig.merge(Toml::file(path)); + } + let cfg: Config = fig.merge(Env::prefixed("NEWSFEED_")).extract()?; + Ok(cfg) + } + + /// Session lifetime as a `chrono::Duration`. + pub fn session_ttl(&self) -> chrono::Duration { + chrono::Duration::days(self.session_ttl_days) + } +} diff --git a/crates/newsfeed-api/src/error.rs b/crates/newsfeed-api/src/error.rs new file mode 100644 index 0000000..e5b1efe --- /dev/null +++ b/crates/newsfeed-api/src/error.rs @@ -0,0 +1,81 @@ +//! HTTP error type. Maps [`CoreError`] and domain validation onto status codes and a +//! small JSON error envelope. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +use newsfeed_core::error::CoreError; + +/// An API error carrying an HTTP status and a client-safe message. +#[derive(Debug)] +pub struct ApiError { + pub status: StatusCode, + pub message: String, +} + +impl ApiError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + } + } + + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized") + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not found") + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } +} + +#[derive(Serialize)] +struct ErrorBody { + error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + // Log server-side faults; client errors are self-explanatory. + if self.status.is_server_error() { + tracing::error!(status = %self.status, message = %self.message, "request failed"); + } + ( + self.status, + Json(ErrorBody { + error: self.message, + }), + ) + .into_response() + } +} + +impl From for ApiError { + fn from(e: CoreError) -> Self { + match e { + CoreError::Domain(d) => ApiError::bad_request(d.to_string()), + CoreError::NotFound => ApiError::not_found(), + CoreError::Conflict(m) => ApiError::new(StatusCode::CONFLICT, m), + CoreError::Unauthorized => ApiError::unauthorized(), + CoreError::Crypto(_) | CoreError::Storage(_) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error") + } + } + } +} + +impl From for ApiError { + fn from(e: newsfeed_entities::Error) -> Self { + ApiError::bad_request(e.to_string()) + } +} + +/// Convenient result alias for handlers. +pub type ApiResult = Result; diff --git a/crates/newsfeed-api/src/main.rs b/crates/newsfeed-api/src/main.rs new file mode 100644 index 0000000..02c41f0 --- /dev/null +++ b/crates/newsfeed-api/src/main.rs @@ -0,0 +1,100 @@ +//! newsfeed REST/JSON API daemon. +//! +//! Thin binary: parse CLI/config, initialise tracing, open the SQLite store (shared with +//! the worker on the same host), build the axum router, and serve until SIGTERM. + +mod auth; +mod config; +mod error; +mod routes; +mod state; + +use std::path::PathBuf; + +use anyhow::Context; +use clap::Parser; +use tokio::net::TcpListener; +use tokio::signal; + +use crate::config::Config; +use crate::state::AppState; + +#[derive(Parser)] +#[command( + name = "newsfeed-api", + version, + about = "newsfeed REST/JSON API daemon" +)] +struct Cli { + /// Path to a TOML config file. Env (`NEWSFEED_*`) overrides file values. + #[arg(long, default_value = "/etc/newsfeed/config.toml")] + config: PathBuf, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + + let cli = Cli::parse(); + let config_file = cli.config.exists().then_some(cli.config.as_path()); + let config = Config::load(config_file).context("loading configuration")?; + tracing::info!(bind = %config.bind, db = %config.database_path.display(), "starting newsfeed-api"); + + if let Some(parent) = config.database_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let store = newsfeed_data::connect(&config.database_path, config.max_db_connections) + .await + .context("opening database")?; + + let bind = config.bind; + let state = AppState::new(store, config); + let app = routes::router(state); + + let listener = TcpListener::bind(bind) + .await + .with_context(|| format!("binding {bind}"))?; + tracing::info!(%bind, "listening"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("server error")?; + tracing::info!("shut down cleanly"); + Ok(()) +} + +/// JSON logs when running under journald (`JOURNAL_STREAM` set), pretty logs on a TTY. +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let registry = tracing_subscriber::registry().with(filter); + if std::env::var_os("JOURNAL_STREAM").is_some() { + registry + .with(fmt::layer().json().flatten_event(true)) + .init(); + } else { + registry.with(fmt::layer()).init(); + } +} + +/// Resolve on SIGTERM (systemd) or Ctrl-C. +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c().await.expect("install Ctrl-C handler"); + }; + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("shutdown signal received"); +} diff --git a/crates/newsfeed-api/src/routes/auth_routes.rs b/crates/newsfeed-api/src/routes/auth_routes.rs new file mode 100644 index 0000000..d24b686 --- /dev/null +++ b/crates/newsfeed-api/src/routes/auth_routes.rs @@ -0,0 +1,83 @@ +//! Registration, login, logout, and the current-principal endpoint. + +use axum::Json; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::IntoResponse; + +use newsfeed_core::service; +use newsfeed_entities::auth::Me; +use newsfeed_entities::user::{LoginRequest, RegisterRequest, User}; + +use crate::auth::{CurrentUser, clear_session_cookie, session_cookie}; +use crate::error::ApiResult; +use crate::state::AppState; + +/// `POST /v1/auth/register` — create an account. Does not log the user in. +pub async fn register( + State(app): State, + Json(req): Json, +) -> ApiResult { + let user = service::register(app.store.as_ref(), &req).await?; + Ok((StatusCode::CREATED, Json(to_me(user)))) +} + +/// `POST /v1/auth/login` — authenticate and open a session, setting the cookie. +pub async fn login( + State(app): State, + Json(req): Json, +) -> ApiResult { + let outcome = service::login(app.store.as_ref(), &req, app.config.session_ttl()).await?; + let cookie = session_cookie( + &outcome.session_cookie, + app.config.session_ttl_days, + app.config.cookie_secure, + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + cookie.parse().expect("valid cookie header"), + ); + Ok((headers, Json(to_me(outcome.user)))) +} + +/// `POST /v1/auth/logout` — end the current session and clear the cookie. +pub async fn logout( + State(app): State, + headers: HeaderMap, +) -> ApiResult { + if let Some(value) = session_cookie_value(&headers) { + service::logout(app.store.as_ref(), &value).await?; + } + let mut out = HeaderMap::new(); + out.insert( + header::SET_COOKIE, + clear_session_cookie(app.config.cookie_secure) + .parse() + .expect("valid cookie header"), + ); + Ok((StatusCode::NO_CONTENT, out)) +} + +/// `GET /v1/auth/me` — the authenticated user. +pub async fn me(CurrentUser(user): CurrentUser) -> Json { + Json(to_me(user)) +} + +fn to_me(user: User) -> Me { + Me { + id: user.id, + username: user.username, + email: user.email, + } +} + +/// Read the raw session cookie value from request headers (logout, before the session is +/// resolved). +fn session_cookie_value(headers: &HeaderMap) -> Option { + let header = headers.get(header::COOKIE)?.to_str().ok()?; + header.split(';').find_map(|pair| { + let (k, v) = pair.trim().split_once('=')?; + (k == crate::auth::SESSION_COOKIE).then(|| v.to_string()) + }) +} diff --git a/crates/newsfeed-api/src/routes/feed.rs b/crates/newsfeed-api/src/routes/feed.rs new file mode 100644 index 0000000..59d3cd8 --- /dev/null +++ b/crates/newsfeed-api/src/routes/feed.rs @@ -0,0 +1,77 @@ +//! The feed read endpoint and interaction signals. + +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; + +use newsfeed_core::ports::ItemStore; +use newsfeed_entities::feed::{FeedPage, FeedQuery, Signal, SignalAction}; +use newsfeed_entities::item::ItemState; + +use crate::auth::CurrentUser; +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +const DEFAULT_LIMIT: i64 = 50; +const MAX_LIMIT: i64 = 100; + +/// `GET /v1/feed` — a ranked, keyset-paginated page of the user's feed. +pub async fn get_feed( + CurrentUser(user): CurrentUser, + State(app): State, + Query(q): Query, +) -> ApiResult> { + let limit = q + .limit + .map(|l| l as i64) + .unwrap_or(DEFAULT_LIMIT) + .clamp(1, MAX_LIMIT); + + // Fetch one extra to determine whether a further page exists. + let mut items = app + .store + .feed_page(user.id, limit + 1, q.cursor.as_deref(), q.include_saved) + .await?; + + let next_cursor = if items.len() as i64 > limit { + items.truncate(limit as usize); + items.last().map(|it| format!("{}:{}", it.score, it.id)) + } else { + None + }; + + Ok(Json(FeedPage { items, next_cursor })) +} + +/// `POST /v1/feed/signals` — record an interaction. Save/Dismiss also move the item's +/// lifecycle state; View/Click are recorded for future ranking feedback. +pub async fn post_signal( + CurrentUser(user): CurrentUser, + State(app): State, + Json(sig): Json, +) -> ApiResult { + // The item must belong to the caller. + if app.store.get_item(user.id, sig.item_id).await?.is_none() { + return Err(ApiError::not_found()); + } + + app.store + .record_signal(user.id, sig.item_id, sig.action) + .await?; + + match sig.action { + SignalAction::Save => { + app.store + .set_item_state(user.id, sig.item_id, ItemState::Saved) + .await? + } + SignalAction::Dismiss => { + app.store + .set_item_state(user.id, sig.item_id, ItemState::Dismissed) + .await? + } + SignalAction::View | SignalAction::Click => {} + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/newsfeed-api/src/routes/health.rs b/crates/newsfeed-api/src/routes/health.rs new file mode 100644 index 0000000..19ebf15 --- /dev/null +++ b/crates/newsfeed-api/src/routes/health.rs @@ -0,0 +1,15 @@ +//! Liveness/readiness probe for systemd and the nginx upstream check. + +use axum::extract::State; +use axum::http::StatusCode; + +use crate::state::AppState; + +/// `GET /health` — 200 when the database is reachable, 503 otherwise. +pub async fn health(State(app): State) -> StatusCode { + if app.store.ping().await { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + } +} diff --git a/crates/newsfeed-api/src/routes/ingest.rs b/crates/newsfeed-api/src/routes/ingest.rs new file mode 100644 index 0000000..79cf05a --- /dev/null +++ b/crates/newsfeed-api/src/routes/ingest.rs @@ -0,0 +1,29 @@ +//! The inbound curation endpoint. Agentic and algorithmic producers authenticate with a +//! per-user bearer API token and POST content candidates; each is normalised, scored +//! against the owning user's interests, and promoted into their feed. + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; + +use newsfeed_core::ingest; +use newsfeed_core::ports::InterestStore; +use newsfeed_entities::item::{CandidateSubmission, ContentItem}; + +use crate::auth::ApiPrincipal; +use crate::error::ApiResult; +use crate::state::AppState; + +/// `POST /v1/ingest/candidates` — submit a candidate for curation into the token owner's +/// feed. Re-submitting an already-seen `external_id` returns the stored item unchanged +/// (idempotent), so producers can safely retry. +pub async fn submit( + principal: ApiPrincipal, + State(app): State, + Json(sub): Json, +) -> ApiResult<(StatusCode, Json)> { + let user_id = principal.user_id(); + let interests = app.store.list_interests(user_id).await?; + let item = ingest::ingest_submission(app.store.as_ref(), user_id, &interests, sub).await?; + Ok((StatusCode::ACCEPTED, Json(item))) +} diff --git a/crates/newsfeed-api/src/routes/interests.rs b/crates/newsfeed-api/src/routes/interests.rs new file mode 100644 index 0000000..6bed4d4 --- /dev/null +++ b/crates/newsfeed-api/src/routes/interests.rs @@ -0,0 +1,41 @@ +//! The user-controlled interest weightings that drive ranking. + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use uuid::Uuid; + +use newsfeed_core::ports::InterestStore; +use newsfeed_entities::interest::{Interest, UpsertInterest}; + +use crate::auth::CurrentUser; +use crate::error::ApiResult; +use crate::state::AppState; + +/// `GET /v1/interests` — the user's interests, strongest first. +pub async fn list( + CurrentUser(user): CurrentUser, + State(app): State, +) -> ApiResult>> { + Ok(Json(app.store.list_interests(user.id).await?)) +} + +/// `PUT /v1/interests` — create or update an interest by label. +pub async fn upsert( + CurrentUser(user): CurrentUser, + State(app): State, + Json(body): Json, +) -> ApiResult> { + body.validate()?; + Ok(Json(app.store.upsert_interest(user.id, &body).await?)) +} + +/// `DELETE /v1/interests/{id}` — remove an interest. +pub async fn remove( + CurrentUser(user): CurrentUser, + State(app): State, + Path(id): Path, +) -> ApiResult { + app.store.delete_interest(user.id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/newsfeed-api/src/routes/mod.rs b/crates/newsfeed-api/src/routes/mod.rs new file mode 100644 index 0000000..51b06b2 --- /dev/null +++ b/crates/newsfeed-api/src/routes/mod.rs @@ -0,0 +1,74 @@ +//! Router assembly. Public API is versioned under `/v1` from day one; `/health` is +//! unversioned for infrastructure probes. + +mod auth_routes; +mod feed; +mod health; +mod ingest; +mod interests; +mod sources; +mod tokens; + +use axum::Router; +use axum::http::{Method, header}; +use axum::routing::{delete, get, post}; +use tower_http::compression::CompressionLayer; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::state::AppState; + +/// Build the full application router. +pub fn router(state: AppState) -> Router { + let cors = build_cors(&state); + + let v1 = Router::new() + // auth + .route("/auth/register", post(auth_routes::register)) + .route("/auth/login", post(auth_routes::login)) + .route("/auth/logout", post(auth_routes::logout)) + .route("/auth/me", get(auth_routes::me)) + // feed + .route("/feed", get(feed::get_feed)) + .route("/feed/signals", post(feed::post_signal)) + // sources + .route("/sources", get(sources::list).post(sources::create)) + .route("/sources/{id}", delete(sources::remove)) + // interests + .route("/interests", get(interests::list).put(interests::upsert)) + .route("/interests/{id}", delete(interests::remove)) + // api tokens + .route("/tokens", get(tokens::list).post(tokens::create)) + .route("/tokens/{id}", delete(tokens::revoke)) + // ingest (bearer-token authenticated) + .route("/ingest/candidates", post(ingest::submit)); + + Router::new() + .route("/health", get(health::health)) + .nest("/v1", v1) + .layer(TraceLayer::new_for_http()) + .layer(CompressionLayer::new()) + .layer(cors) + .with_state(state) +} + +/// CORS is only needed in development, where the Vite dev server is a different origin +/// from the API. In production nginx serves the SPA and proxies the API under one origin, +/// so `cors_origins` is empty and no cross-origin headers are emitted. +fn build_cors(state: &AppState) -> CorsLayer { + if state.config.cors_origins.is_empty() { + return CorsLayer::new(); + } + let origins = state + .config + .cors_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect::>(); + // Credentialed CORS forbids wildcard method/header lists, so enumerate them. + CorsLayer::new() + .allow_origin(origins) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) + .allow_credentials(true) +} diff --git a/crates/newsfeed-api/src/routes/sources.rs b/crates/newsfeed-api/src/routes/sources.rs new file mode 100644 index 0000000..9600d34 --- /dev/null +++ b/crates/newsfeed-api/src/routes/sources.rs @@ -0,0 +1,42 @@ +//! CRUD for a user's content sources. + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use uuid::Uuid; + +use newsfeed_core::ports::SourceStore; +use newsfeed_entities::source::{NewSource, Source}; + +use crate::auth::CurrentUser; +use crate::error::ApiResult; +use crate::state::AppState; + +/// `GET /v1/sources` — the user's sources. +pub async fn list( + CurrentUser(user): CurrentUser, + State(app): State, +) -> ApiResult>> { + Ok(Json(app.store.list_sources(user.id).await?)) +} + +/// `POST /v1/sources` — add a source. +pub async fn create( + CurrentUser(user): CurrentUser, + State(app): State, + Json(new): Json, +) -> ApiResult<(StatusCode, Json)> { + new.validate()?; + let source = app.store.create_source(user.id, &new).await?; + Ok((StatusCode::CREATED, Json(source))) +} + +/// `DELETE /v1/sources/{id}` — remove a source. +pub async fn remove( + CurrentUser(user): CurrentUser, + State(app): State, + Path(id): Path, +) -> ApiResult { + app.store.delete_source(user.id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/newsfeed-api/src/routes/tokens.rs b/crates/newsfeed-api/src/routes/tokens.rs new file mode 100644 index 0000000..1147ebe --- /dev/null +++ b/crates/newsfeed-api/src/routes/tokens.rs @@ -0,0 +1,45 @@ +//! Per-user API tokens used by agentic/algorithmic producers to POST candidates. + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use uuid::Uuid; + +use newsfeed_core::ports::TokenStore; +use newsfeed_core::service; +use newsfeed_entities::auth::{ApiTokenInfo, CreateApiToken, CreatedApiToken}; + +use crate::auth::CurrentUser; +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +/// `GET /v1/tokens` — list the caller's tokens (metadata only; secrets never returned). +pub async fn list( + CurrentUser(user): CurrentUser, + State(app): State, +) -> ApiResult>> { + Ok(Json(app.store.list_tokens(user.id).await?)) +} + +/// `POST /v1/tokens` — mint a token. The full secret is returned exactly once. +pub async fn create( + CurrentUser(user): CurrentUser, + State(app): State, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + if body.name.trim().is_empty() { + return Err(ApiError::bad_request("token name must not be empty")); + } + let created = service::create_api_token(app.store.as_ref(), user.id, body.name.trim()).await?; + Ok((StatusCode::CREATED, Json(created))) +} + +/// `DELETE /v1/tokens/{id}` — revoke a token. +pub async fn revoke( + CurrentUser(user): CurrentUser, + State(app): State, + Path(id): Path, +) -> ApiResult { + app.store.revoke_token(user.id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/newsfeed-api/src/state.rs b/crates/newsfeed-api/src/state.rs new file mode 100644 index 0000000..c54b469 --- /dev/null +++ b/crates/newsfeed-api/src/state.rs @@ -0,0 +1,24 @@ +//! Shared application state. The binary wires the concrete SQLite adapter here; handlers +//! reach the ports through it. `AppState` is cheap to clone (Arc-wrapped). + +use std::sync::Arc; + +use newsfeed_data::SqliteStore; + +use crate::config::Config; + +/// State shared across all handlers. +#[derive(Clone)] +pub struct AppState { + pub store: Arc, + pub config: Arc, +} + +impl AppState { + pub fn new(store: SqliteStore, config: Config) -> Self { + Self { + store: Arc::new(store), + config: Arc::new(config), + } + } +} diff --git a/crates/newsfeed-core/Cargo.toml b/crates/newsfeed-core/Cargo.toml new file mode 100644 index 0000000..0bcc5bf --- /dev/null +++ b/crates/newsfeed-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "newsfeed-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Business logic and data-access ports for newsfeed. No direct I/O." + +[dependencies] +newsfeed-entities.workspace = true + +serde.workspace = true +chrono.workspace = true +uuid.workspace = true +thiserror.workspace = true +async-trait.workspace = true + +# pure crypto/compute — no network or DB +argon2.workspace = true +rand.workspace = true +sha2.workspace = true +base64.workspace = true diff --git a/crates/newsfeed-core/src/auth.rs b/crates/newsfeed-core/src/auth.rs new file mode 100644 index 0000000..95ef5d9 --- /dev/null +++ b/crates/newsfeed-core/src/auth.rs @@ -0,0 +1,126 @@ +//! Credential primitives: password hashing, opaque session tokens, and API tokens. +//! +//! Secrets are generated here and only their hashes are handed to the store. Session +//! cookies are random and hashed with SHA-256 (they are high-entropy, so a fast hash is +//! appropriate); passwords are hashed with Argon2id (slow, salted) because they are +//! low-entropy and attacker-guessable. + +use argon2::Argon2; +use argon2::password_hash::{ + PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, +}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +use crate::error::{CoreError, CoreResult}; + +/// Hash a plaintext password with Argon2id and a fresh random salt. +pub fn hash_password(plaintext: &str) -> CoreResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(plaintext.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| CoreError::Crypto(e.to_string())) +} + +/// Verify a plaintext password against a stored Argon2 PHC hash. +pub fn verify_password(plaintext: &str, phc_hash: &str) -> CoreResult { + let parsed = PasswordHash::new(phc_hash).map_err(|e| CoreError::Crypto(e.to_string()))?; + Ok(Argon2::default() + .verify_password(plaintext.as_bytes(), &parsed) + .is_ok()) +} + +/// A freshly minted secret: the plaintext to hand to the client exactly once, plus the +/// hash to persist. +pub struct MintedSecret { + /// The value the client keeps (cookie value or bearer token). + pub plaintext: String, + /// The SHA-256 hash to store and look up by. + pub hash: String, +} + +fn random_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + OsRng.fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +/// SHA-256 hash of an opaque high-entropy secret, hex-encoded. Used for both session +/// cookies and API tokens so the store never holds the secret in the clear. +pub fn hash_opaque(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + hex_encode(&digest) +} + +/// Generate a new opaque session token (256 bits of entropy). +pub fn mint_session_token() -> MintedSecret { + let plaintext = random_token(32); + let hash = hash_opaque(&plaintext); + MintedSecret { plaintext, hash } +} + +/// A newly minted API token, with its display prefix. +pub struct MintedApiToken { + /// Full bearer token: `nf__`. + pub secret: String, + /// Short non-secret prefix shown to the user (`nf_`). + pub prefix: String, + /// SHA-256 hash of the full secret, for storage. + pub hash: String, +} + +/// Prefix on every API token. Lets the ingest handler cheaply reject non-tokens. +pub const API_TOKEN_PREFIX: &str = "nf"; + +/// Generate a new API token of the form `nf_<6hex>_`. +pub fn mint_api_token() -> MintedApiToken { + let mut id = [0u8; 3]; + OsRng.fill_bytes(&mut id); + let short = hex_encode(&id); // 6 hex chars + let prefix = format!("{API_TOKEN_PREFIX}_{short}"); + let secret = format!("{prefix}_{}", random_token(24)); + let hash = hash_opaque(&secret); + MintedApiToken { + secret, + prefix, + hash, + } +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_roundtrip() { + let hash = hash_password("correct horse battery staple").unwrap(); + assert!(verify_password("correct horse battery staple", &hash).unwrap()); + assert!(!verify_password("wrong", &hash).unwrap()); + } + + #[test] + fn opaque_hash_is_stable_and_matches() { + let minted = mint_session_token(); + assert_eq!(minted.hash, hash_opaque(&minted.plaintext)); + } + + #[test] + fn api_token_has_prefix() { + let t = mint_api_token(); + assert!(t.secret.starts_with(&t.prefix)); + assert!(t.prefix.starts_with("nf_")); + assert_eq!(t.hash, hash_opaque(&t.secret)); + } +} diff --git a/crates/newsfeed-core/src/error.rs b/crates/newsfeed-core/src/error.rs new file mode 100644 index 0000000..bf93e42 --- /dev/null +++ b/crates/newsfeed-core/src/error.rs @@ -0,0 +1,36 @@ +//! Error type for the core layer. Wraps domain validation errors and the abstract +//! failure modes a data adapter can surface, without naming any concrete backend. + +use thiserror::Error; + +/// Errors produced by core business logic and its data ports. +#[derive(Debug, Error)] +pub enum CoreError { + /// A domain value failed validation. + #[error(transparent)] + Domain(#[from] newsfeed_entities::Error), + + /// The requested entity does not exist. + #[error("not found")] + NotFound, + + /// A uniqueness constraint was violated (duplicate username, email, …). + #[error("conflict: {0}")] + Conflict(String), + + /// Authentication failed (bad password, unknown/expired session or token). + #[error("unauthorized")] + Unauthorized, + + /// A password/token hashing operation failed. + #[error("crypto error: {0}")] + Crypto(String), + + /// An adapter (the `data` crate) failed for an implementation-specific reason. + /// The concrete error is stringified so `core` needn't depend on any backend. + #[error("storage error: {0}")] + Storage(String), +} + +/// Result alias for the core layer. +pub type CoreResult = std::result::Result; diff --git a/crates/newsfeed-core/src/ingest.rs b/crates/newsfeed-core/src/ingest.rs new file mode 100644 index 0000000..2c94a34 --- /dev/null +++ b/crates/newsfeed-core/src/ingest.rs @@ -0,0 +1,121 @@ +//! Candidate normalisation. Turns an inbound [`CandidateSubmission`] (from an agentic +//! producer) or a parsed feed entry into the [`NewCandidate`] shape the store persists. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use newsfeed_entities::interest::Interest; +use newsfeed_entities::item::{CandidateSubmission, ContentItem, ItemState, Media}; + +use crate::error::CoreResult; +use crate::ports::{ItemStore, NewCandidate, SourceStore}; +use crate::ranking::{self, RankingParams}; + +/// Baseline weight applied to candidates whose named source doesn't (yet) exist as a +/// configured source for the user. Configured sources carry their own weight. +pub const DEFAULT_UNLINKED_WEIGHT: f64 = 0.5; + +/// Normalise an agentic submission for a given user and (optional) resolved source. +pub fn candidate_from_submission( + user_id: Uuid, + source_id: Option, + sub: CandidateSubmission, +) -> NewCandidate { + NewCandidate { + user_id, + source_id, + external_id: sub.external_id, + url: sub.url, + title: sub.title, + summary: sub.summary, + author: sub.author, + tags: sub.tags, + media: sub.media, + published_at: sub.published_at, + } +} + +/// Fields extracted from a polled feed entry, backend-agnostic so the worker's feed +/// parser (`feed-rs`) doesn't leak into `core`. +pub struct FeedEntry { + pub external_id: String, + pub url: Option, + pub title: String, + pub summary: Option, + pub author: Option, + pub tags: Vec, + pub media: Vec, + pub published_at: Option>, +} + +/// Normalise a polled feed entry for a user's RSS source. +pub fn candidate_from_feed_entry(user_id: Uuid, source_id: Uuid, entry: FeedEntry) -> NewCandidate { + NewCandidate { + user_id, + source_id: Some(source_id), + external_id: entry.external_id, + url: entry.url, + title: entry.title, + summary: entry.summary, + author: entry.author, + tags: entry.tags, + media: entry.media, + published_at: entry.published_at, + } +} + +/// Persist a normalised candidate and, if it is newly created, score it against the +/// user's current interests and promote it into the live feed. Re-submitting an existing +/// `(user, external_id)` is a no-op that returns the stored item unchanged. `interests` +/// is passed in so the caller can load it once per batch. +pub async fn persist_and_rank( + store: &S, + candidate: NewCandidate, + source_weight: f64, + interests: &[Interest], +) -> CoreResult +where + S: ItemStore, +{ + let (mut item, created) = store.upsert_candidate(&candidate).await?; + if created { + let score = ranking::score_item( + &item, + source_weight, + interests, + Utc::now(), + &RankingParams::default(), + ); + store.update_score(item.id, score).await?; + store + .set_item_state(item.user_id, item.id, ItemState::Feed) + .await?; + item.score = score; + item.state = ItemState::Feed; + } + Ok(item) +} + +/// Ingest an agentic submission: resolve its named source (if any), normalise, then +/// [`persist_and_rank`]. Used by `POST /v1/ingest/candidates`. +pub async fn ingest_submission( + store: &S, + user_id: Uuid, + interests: &[Interest], + sub: CandidateSubmission, +) -> CoreResult +where + S: SourceStore + ItemStore, +{ + let source = match &sub.source { + Some(name) => store.find_source_by_name(user_id, name).await?, + None => None, + }; + let source_weight = source + .as_ref() + .map(|s| s.weight) + .unwrap_or(DEFAULT_UNLINKED_WEIGHT); + let source_id = source.as_ref().map(|s| s.id); + let candidate = candidate_from_submission(user_id, source_id, sub); + persist_and_rank(store, candidate, source_weight, interests).await +} diff --git a/crates/newsfeed-core/src/lib.rs b/crates/newsfeed-core/src/lib.rs new file mode 100644 index 0000000..1c845db --- /dev/null +++ b/crates/newsfeed-core/src/lib.rs @@ -0,0 +1,15 @@ +//! Business logic and data-access ports for newsfeed. +//! +//! This crate consumes [`newsfeed_entities`] and defines the *ports* (traits in +//! [`ports`]) that `newsfeed-data` implements as adapters. It also holds pure compute: +//! password/token hashing ([`auth`]), the feed ranker ([`ranking`]), and candidate +//! normalisation ([`ingest`]). It performs no I/O of its own. + +pub mod auth; +pub mod error; +pub mod ingest; +pub mod ports; +pub mod ranking; +pub mod service; + +pub use error::{CoreError, CoreResult}; diff --git a/crates/newsfeed-core/src/ports.rs b/crates/newsfeed-core/src/ports.rs new file mode 100644 index 0000000..186f239 --- /dev/null +++ b/crates/newsfeed-core/src/ports.rs @@ -0,0 +1,156 @@ +//! Data-access ports. `newsfeed-data` provides the adapters that implement these; the +//! binaries depend only on the [`Store`] aggregate, never on a concrete backend. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use newsfeed_entities::auth::{ApiTokenInfo, Session}; +use newsfeed_entities::interest::{Interest, UpsertInterest}; +use newsfeed_entities::item::ContentItem; +use newsfeed_entities::source::{NewSource, Source}; +use newsfeed_entities::user::User; + +use crate::error::CoreResult; + +/// A user together with the password hash needed to authenticate them. Never serialised +/// to a client — it exists only to move the hash from the store to [`crate::auth`]. +pub struct StoredCredential { + pub user: User, + pub password_hash: String, +} + +/// Fields required to persist a candidate. Normalised from a submission or a polled feed +/// entry by [`crate::ingest`]. +pub struct NewCandidate { + pub user_id: Uuid, + pub source_id: Option, + pub external_id: String, + pub url: Option, + pub title: String, + pub summary: Option, + pub author: Option, + pub tags: Vec, + pub media: Vec, + pub published_at: Option>, +} + +/// User accounts. +#[async_trait] +pub trait UserStore: Send + Sync { + async fn create_user( + &self, + username: &str, + email: &str, + password_hash: &str, + ) -> CoreResult; + async fn get_user(&self, id: Uuid) -> CoreResult>; + /// Look up by username or email, returning the stored password hash for verification. + async fn find_credential(&self, identifier: &str) -> CoreResult>; +} + +/// Browser sessions. +#[async_trait] +pub trait SessionStore: Send + Sync { + async fn create_session( + &self, + user_id: Uuid, + token_hash: &str, + expires_at: DateTime, + ) -> CoreResult; + /// Return the session for this cookie hash iff it exists and has not expired. + async fn find_session(&self, token_hash: &str) -> CoreResult>; + async fn delete_session(&self, token_hash: &str) -> CoreResult<()>; +} + +/// Per-user API tokens used by agentic/algorithmic ingest producers. +#[async_trait] +pub trait TokenStore: Send + Sync { + async fn create_token( + &self, + user_id: Uuid, + name: &str, + prefix: &str, + token_hash: &str, + ) -> CoreResult; + async fn list_tokens(&self, user_id: Uuid) -> CoreResult>; + /// Resolve a bearer-token hash to its (non-revoked) owner and record use. + async fn authenticate_token(&self, token_hash: &str) -> CoreResult>; + async fn revoke_token(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>; +} + +/// Content sources. +#[async_trait] +pub trait SourceStore: Send + Sync { + async fn create_source(&self, user_id: Uuid, new: &NewSource) -> CoreResult; + async fn list_sources(&self, user_id: Uuid) -> CoreResult>; + async fn find_source_by_name(&self, user_id: Uuid, name: &str) -> CoreResult>; + async fn delete_source(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>; + /// RSS sources whose next poll is due (worker use). + async fn due_rss_sources( + &self, + now: DateTime, + min_interval_secs: i64, + limit: i64, + ) -> CoreResult>; + async fn mark_polled(&self, source_id: Uuid, at: DateTime) -> CoreResult<()>; +} + +/// User interests / weightings. +#[async_trait] +pub trait InterestStore: Send + Sync { + async fn list_interests(&self, user_id: Uuid) -> CoreResult>; + async fn upsert_interest(&self, user_id: Uuid, upsert: &UpsertInterest) + -> CoreResult; + async fn delete_interest(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>; +} + +/// Content items and the feed. +#[async_trait] +pub trait ItemStore: Send + Sync { + /// Insert a candidate, or return the existing row if `(user_id, external_id)` already + /// exists. `true` in the tuple means a new row was created. + async fn upsert_candidate(&self, candidate: &NewCandidate) -> CoreResult<(ContentItem, bool)>; + async fn get_item(&self, user_id: Uuid, id: Uuid) -> CoreResult>; + async fn set_item_state( + &self, + user_id: Uuid, + id: Uuid, + state: newsfeed_entities::item::ItemState, + ) -> CoreResult<()>; + async fn update_score(&self, item_id: Uuid, score: f64) -> CoreResult<()>; + /// A ranked page of the user's feed, ordered by score descending. + async fn feed_page( + &self, + user_id: Uuid, + limit: i64, + cursor: Option<&str>, + include_saved: bool, + ) -> CoreResult>; + /// All feed/candidate items for a user, for (re)scoring by the worker. + async fn items_for_scoring(&self, user_id: Uuid, limit: i64) -> CoreResult>; + /// Distinct user ids that own at least one source (worker iteration). + async fn user_ids_with_sources(&self) -> CoreResult>; + + /// Record an interaction signal against an item. This is the feedback the ranker can + /// later weigh (still under the user's control — signals inform, they don't override + /// the user's explicit weights). + async fn record_signal( + &self, + user_id: Uuid, + item_id: Uuid, + action: newsfeed_entities::feed::SignalAction, + ) -> CoreResult<()>; +} + +/// The aggregate every binary depends on. Any type implementing all the ports is a +/// `Store`, so the concrete adapter satisfies it automatically. +pub trait Store: + UserStore + SessionStore + TokenStore + SourceStore + InterestStore + ItemStore +{ +} + +impl Store for T where + T: UserStore + SessionStore + TokenStore + SourceStore + InterestStore + ItemStore +{ +} diff --git a/crates/newsfeed-core/src/ranking.rs b/crates/newsfeed-core/src/ranking.rs new file mode 100644 index 0000000..ca10000 --- /dev/null +++ b/crates/newsfeed-core/src/ranking.rs @@ -0,0 +1,156 @@ +//! The feed ranker. This is the whole point of newsfeed: the score assigned to an item +//! is a transparent, deterministic function of weights the *user* controls — their +//! per-source baseline weight and their per-interest weights — decayed by age. No +//! opaque engagement model, no third party deciding what surfaces. + +use chrono::{DateTime, Utc}; + +use newsfeed_entities::interest::Interest; +use newsfeed_entities::item::ContentItem; + +/// Tunable ranking parameters. Defaults are sensible; a future per-user settings row can +/// override them so the decay curve itself is user-controlled too. +#[derive(Debug, Clone, Copy)] +pub struct RankingParams { + /// Hours after which recency weight halves. + pub half_life_hours: f64, +} + +impl Default for RankingParams { + fn default() -> Self { + Self { + half_life_hours: 24.0, + } + } +} + +/// Compute the relevance component from the user's interests: the sum of the weights of +/// every interest whose (lower-cased) label occurs in the item's title, summary or tags. +/// Negative-weighted interests subtract, actively burying matching content. +pub fn interest_relevance(item: &ContentItem, interests: &[Interest]) -> f64 { + if interests.is_empty() { + return 0.0; + } + let haystack = { + let mut s = String::new(); + s.push_str(&item.title.to_lowercase()); + s.push(' '); + if let Some(summary) = &item.summary { + s.push_str(&summary.to_lowercase()); + s.push(' '); + } + for tag in &item.tags { + s.push_str(&tag.to_lowercase()); + s.push(' '); + } + s + }; + interests + .iter() + .filter(|i| { + let label = i.label.trim().to_lowercase(); + !label.is_empty() && haystack.contains(&label) + }) + .map(|i| i.weight) + .sum() +} + +/// Recency multiplier in `(0.0, 1.0]` using exponential decay. Items with no publish +/// date are treated as freshly ingested (multiplier `1.0`). +pub fn recency_multiplier( + published_at: Option>, + now: DateTime, + params: &RankingParams, +) -> f64 { + let Some(published) = published_at else { + return 1.0; + }; + let age_hours = (now - published).num_seconds() as f64 / 3600.0; + if age_hours <= 0.0 { + return 1.0; + } + 0.5f64.powf(age_hours / params.half_life_hours) +} + +/// Score a single item for a user. `source_weight` is the item's source baseline +/// (`0.0..=1.0`); `interests` are the user's weighted interests. +/// +/// `score = (source_weight + interest_relevance) * recency_multiplier` +pub fn score_item( + item: &ContentItem, + source_weight: f64, + interests: &[Interest], + now: DateTime, + params: &RankingParams, +) -> f64 { + let relevance = source_weight + interest_relevance(item, interests); + relevance * recency_multiplier(item.published_at, now, params) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use uuid::Uuid; + + fn item(title: &str, published: Option>) -> ContentItem { + ContentItem { + id: Uuid::nil(), + user_id: Uuid::nil(), + source_id: None, + external_id: "x".into(), + url: None, + title: title.into(), + summary: None, + author: None, + tags: vec![], + media: vec![], + published_at: published, + ingested_at: Utc::now(), + state: newsfeed_entities::item::ItemState::Feed, + score: 0.0, + } + } + + fn interest(label: &str, weight: f64) -> Interest { + Interest { + id: Uuid::nil(), + user_id: Uuid::nil(), + label: label.into(), + weight, + created_at: Utc::now(), + } + } + + #[test] + fn positive_interest_raises_score() { + let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap(); + let it = item("Rust 2.0 released", Some(now)); + let interests = vec![interest("rust", 0.8)]; + let with = score_item(&it, 0.5, &interests, now, &RankingParams::default()); + let without = score_item(&it, 0.5, &[], now, &RankingParams::default()); + assert!(with > without); + } + + #[test] + fn negative_interest_buries() { + let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap(); + let it = item("Celebrity gossip roundup", Some(now)); + let interests = vec![interest("gossip", -0.9)]; + let score = score_item(&it, 0.5, &interests, now, &RankingParams::default()); + assert!(score < 0.0); + } + + #[test] + fn older_items_decay() { + let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap(); + let fresh = item("news", Some(now)); + let day_old = item("news", Some(now - chrono::Duration::hours(24))); + let p = RankingParams::default(); + let fresh_score = score_item(&fresh, 1.0, &[], now, &p); + let old_score = score_item(&day_old, 1.0, &[], now, &p); + assert!(fresh_score > old_score); + // one half-life => ~half the score + assert!((old_score - 0.5).abs() < 0.01); + } +} diff --git a/crates/newsfeed-core/src/service.rs b/crates/newsfeed-core/src/service.rs new file mode 100644 index 0000000..4f7b74a --- /dev/null +++ b/crates/newsfeed-core/src/service.rs @@ -0,0 +1,131 @@ +//! Service-layer orchestration that spans the ports: registration, login, and principal +//! resolution. The binaries call these so the auth flow lives in one tested place rather +//! than being re-implemented per handler. + +use chrono::{Duration, Utc}; + +use newsfeed_entities::auth::{ApiTokenInfo, CreatedApiToken}; +use newsfeed_entities::user::{LoginRequest, RegisterRequest, User}; + +use std::collections::HashMap; + +use crate::auth; +use crate::error::{CoreError, CoreResult}; +use crate::ports::{InterestStore, ItemStore, SessionStore, SourceStore, TokenStore, UserStore}; +use crate::ranking::{self, RankingParams}; + +/// Register a new user. Validates the request, hashes the password, and persists. +pub async fn register(store: &impl UserStore, req: &RegisterRequest) -> CoreResult { + req.validate()?; + let hash = auth::hash_password(&req.password)?; + store + .create_user(req.username.trim(), req.email.trim(), &hash) + .await +} + +/// The outcome of a successful login: the user plus the opaque session cookie value to +/// set (returned in the clear exactly once). +pub struct LoggedIn { + pub user: User, + /// The cookie value to hand to the browser. + pub session_cookie: String, +} + +/// Authenticate a login request and open a session valid for `ttl`. +pub async fn login(store: &S, req: &LoginRequest, ttl: Duration) -> CoreResult +where + S: UserStore + SessionStore, +{ + let cred = store + .find_credential(req.identifier.trim()) + .await? + .ok_or(CoreError::Unauthorized)?; + if !auth::verify_password(&req.password, &cred.password_hash)? { + return Err(CoreError::Unauthorized); + } + let minted = auth::mint_session_token(); + let expires_at = Utc::now() + ttl; + store + .create_session(cred.user.id, &minted.hash, expires_at) + .await?; + Ok(LoggedIn { + user: cred.user, + session_cookie: minted.plaintext, + }) +} + +/// Resolve a session cookie value to its user, or `Unauthorized`. +pub async fn authenticate_session(store: &S, cookie_value: &str) -> CoreResult +where + S: SessionStore + UserStore, +{ + let hash = auth::hash_opaque(cookie_value); + let session = store + .find_session(&hash) + .await? + .ok_or(CoreError::Unauthorized)?; + store + .get_user(session.user_id) + .await? + .ok_or(CoreError::Unauthorized) +} + +/// Log out: delete the session backing this cookie value. Idempotent. +pub async fn logout(store: &impl SessionStore, cookie_value: &str) -> CoreResult<()> { + store.delete_session(&auth::hash_opaque(cookie_value)).await +} + +/// Resolve a bearer API token to its owning token record, recording use. +pub async fn authenticate_token(store: &impl TokenStore, bearer: &str) -> CoreResult { + let hash = auth::hash_opaque(bearer); + store + .authenticate_token(&hash) + .await? + .ok_or(CoreError::Unauthorized) +} + +/// Recompute scores for a user's live/candidate items against their current interests +/// and per-source weights. Run periodically by the worker so that changing a weight +/// re-ranks the existing feed, not just future items. Returns the number rescored. +pub async fn rescore_user(store: &S, user_id: uuid::Uuid, limit: i64) -> CoreResult +where + S: SourceStore + InterestStore + ItemStore, +{ + let interests = store.list_interests(user_id).await?; + let weights: HashMap = store + .list_sources(user_id) + .await? + .into_iter() + .map(|s| (s.id, s.weight)) + .collect(); + let items = store.items_for_scoring(user_id, limit).await?; + let now = Utc::now(); + let params = RankingParams::default(); + let mut n = 0; + for item in items { + let source_weight = item + .source_id + .and_then(|id| weights.get(&id).copied()) + .unwrap_or(crate::ingest::DEFAULT_UNLINKED_WEIGHT); + let score = ranking::score_item(&item, source_weight, &interests, now, ¶ms); + store.update_score(item.id, score).await?; + n += 1; + } + Ok(n) +} + +/// Mint a new API token for a user, returning the secret exactly once. +pub async fn create_api_token( + store: &impl TokenStore, + user_id: uuid::Uuid, + name: &str, +) -> CoreResult { + let minted = auth::mint_api_token(); + let info = store + .create_token(user_id, name, &minted.prefix, &minted.hash) + .await?; + Ok(CreatedApiToken { + info, + secret: minted.secret, + }) +} diff --git a/crates/newsfeed-data/Cargo.toml b/crates/newsfeed-data/Cargo.toml new file mode 100644 index 0000000..1a7f9d2 --- /dev/null +++ b/crates/newsfeed-data/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "newsfeed-data" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "SQLite data-access adapters implementing the newsfeed-core ports." + +[dependencies] +newsfeed-entities.workspace = true +newsfeed-core.workspace = true + +sqlx.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +async-trait.workspace = true +tracing.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/newsfeed-data/migrations/0001_init.sql b/crates/newsfeed-data/migrations/0001_init.sql new file mode 100644 index 0000000..19dd537 --- /dev/null +++ b/crates/newsfeed-data/migrations/0001_init.sql @@ -0,0 +1,92 @@ +-- newsfeed initial schema (SQLite). +-- +-- Conventions for this datastore: +-- * ids TEXT — hyphenated UUIDv4 +-- * timestamps TEXT — RFC3339 / ISO8601 (UTC) +-- * booleans INTEGER — 0/1 +-- * json blobs TEXT — serde_json arrays/objects +-- +-- Every user-owned row carries user_id and cascades on user deletion, because each +-- user is wholly in control of — and isolated within — their own feed. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_user ON sessions(user_id); + +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); +CREATE INDEX idx_api_tokens_user ON api_tokens(user_id); + +CREATE TABLE sources ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'rss' | 'agentic' + name TEXT NOT NULL, + url TEXT, + weight REAL NOT NULL DEFAULT 1.0, + enabled INTEGER NOT NULL DEFAULT 1, + last_polled_at TEXT, + created_at TEXT NOT NULL, + UNIQUE(user_id, name) +); +CREATE INDEX idx_sources_user ON sources(user_id); + +CREATE TABLE interests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + weight REAL NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(user_id, label) +); +CREATE INDEX idx_interests_user ON interests(user_id); + +CREATE TABLE items ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + external_id TEXT NOT NULL, + url TEXT, + title TEXT NOT NULL, + summary TEXT, + author TEXT, + tags TEXT NOT NULL DEFAULT '[]', -- json array of strings + media TEXT NOT NULL DEFAULT '[]', -- json array of Media + published_at TEXT, + ingested_at TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'candidate', + score REAL NOT NULL DEFAULT 0.0, + UNIQUE(user_id, external_id) +); +-- Feed reads order by score within a user's live items. +CREATE INDEX idx_items_feed ON items(user_id, state, score DESC, id DESC); + +CREATE TABLE signals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + action TEXT NOT NULL, -- 'view' | 'click' | 'save' | 'dismiss' + created_at TEXT NOT NULL +); +CREATE INDEX idx_signals_item ON signals(item_id); diff --git a/crates/newsfeed-data/src/err.rs b/crates/newsfeed-data/src/err.rs new file mode 100644 index 0000000..798070d --- /dev/null +++ b/crates/newsfeed-data/src/err.rs @@ -0,0 +1,24 @@ +//! Maps `sqlx` failures onto the backend-agnostic [`CoreError`] the ports speak. + +use newsfeed_core::error::CoreError; + +/// Convert a `sqlx::Error` into a [`CoreError`], distinguishing unique-constraint +/// violations (→ [`CoreError::Conflict`]) from everything else (→ storage error). +pub fn map(e: sqlx::Error) -> CoreError { + if let sqlx::Error::Database(db) = &e { + if db.is_unique_violation() { + return CoreError::Conflict(db.message().to_string()); + } + } + CoreError::Storage(e.to_string()) +} + +/// Convert a JSON (de)serialisation failure in the mapping layer into a storage error. +pub fn json(e: serde_json::Error) -> CoreError { + CoreError::Storage(format!("json: {e}")) +} + +/// Convert a malformed stored UUID into a storage error. +pub fn uuid(e: uuid::Error) -> CoreError { + CoreError::Storage(format!("uuid: {e}")) +} diff --git a/crates/newsfeed-data/src/lib.rs b/crates/newsfeed-data/src/lib.rs new file mode 100644 index 0000000..6d85a83 --- /dev/null +++ b/crates/newsfeed-data/src/lib.rs @@ -0,0 +1,52 @@ +//! SQLite data-access adapters for newsfeed. +//! +//! [`SqliteStore`] implements every port defined in [`newsfeed_core::ports`]; the +//! binaries hold it behind `Arc`. Ids are TEXT UUIDs, timestamps RFC3339, +//! and small collections JSON — see [`rows`] for the mapping layer. +//! +//! Note: this crate uses runtime `sqlx` queries rather than the compile-time `query!` +//! macros. On SQLite (dynamically typed) the macros add little safety while forcing a +//! live database or a fiddly offline cache into CI; runtime queries keep CI DB-free. +//! This is a deliberate, documented deviation from architecture `generic.md` §5, which +//! is written for Postgres. + +mod err; +mod rows; +mod store; + +use std::path::Path; +use std::str::FromStr; + +use sqlx::sqlite::{ + SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, +}; + +pub use store::SqliteStore; + +/// Embedded migrations, run at startup by [`connect`]. +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + +/// Open (creating if absent) the SQLite database at `path`, apply migrations, and return +/// a ready [`SqliteStore`]. WAL + `NORMAL` synchronous + `foreign_keys` are enabled; +/// these suit a single-host api+worker sharing one file (the SQLite deployment shape). +pub async fn connect(path: impl AsRef, max_connections: u32) -> anyhow::Result { + let pool = pool(path, max_connections).await?; + MIGRATOR.run(&pool).await?; + Ok(SqliteStore::new(pool)) +} + +/// Build a connection pool with the newsfeed pragmas applied to every connection. +pub async fn pool(path: impl AsRef, max_connections: u32) -> anyhow::Result { + let url = format!("sqlite://{}", path.as_ref().display()); + let opts = SqliteConnectOptions::from_str(&url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(max_connections) + .connect_with(opts) + .await?; + Ok(pool) +} diff --git a/crates/newsfeed-data/src/rows.rs b/crates/newsfeed-data/src/rows.rs new file mode 100644 index 0000000..0afc109 --- /dev/null +++ b/crates/newsfeed-data/src/rows.rs @@ -0,0 +1,197 @@ +//! `FromRow` structs mirroring the SQLite tables, plus conversions into the entity +//! types. DB-native types (TEXT ids, RFC3339 timestamps, JSON blobs) are decoded here so +//! the rest of the crate deals only in [`newsfeed_entities`] values. + +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::StoredCredential; +use newsfeed_entities::auth::{ApiTokenInfo, Session}; +use newsfeed_entities::interest::Interest; +use newsfeed_entities::item::{ContentItem, ItemState, Media}; +use newsfeed_entities::source::{Source, SourceKind}; +use newsfeed_entities::user::User; + +use crate::err; + +fn parse_id(s: &str) -> CoreResult { + Uuid::parse_str(s).map_err(err::uuid) +} + +fn parse_state(s: &str) -> ItemState { + match s { + "feed" => ItemState::Feed, + "saved" => ItemState::Saved, + "dismissed" => ItemState::Dismissed, + _ => ItemState::Candidate, + } +} + +#[derive(FromRow)] +pub struct UserRow { + pub id: String, + pub username: String, + pub email: String, + pub password_hash: String, + pub created_at: DateTime, +} + +impl UserRow { + pub fn into_user(self) -> CoreResult { + Ok(User { + id: parse_id(&self.id)?, + username: self.username, + email: self.email, + created_at: self.created_at, + }) + } + + pub fn into_credential(self) -> CoreResult { + let password_hash = self.password_hash.clone(); + Ok(StoredCredential { + user: self.into_user()?, + password_hash, + }) + } +} + +#[derive(FromRow)] +pub struct SessionRow { + pub id: String, + pub user_id: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl SessionRow { + pub fn into_session(self) -> CoreResult { + Ok(Session { + id: parse_id(&self.id)?, + user_id: parse_id(&self.user_id)?, + created_at: self.created_at, + expires_at: self.expires_at, + }) + } +} + +#[derive(FromRow)] +pub struct ApiTokenRow { + pub id: String, + pub user_id: String, + pub name: String, + pub prefix: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +impl ApiTokenRow { + pub fn into_info(self) -> CoreResult { + Ok(ApiTokenInfo { + id: parse_id(&self.id)?, + user_id: parse_id(&self.user_id)?, + name: self.name, + prefix: self.prefix, + created_at: self.created_at, + last_used_at: self.last_used_at, + revoked_at: self.revoked_at, + }) + } +} + +#[derive(FromRow)] +pub struct SourceRow { + pub id: String, + pub user_id: String, + pub kind: String, + pub name: String, + pub url: Option, + pub weight: f64, + pub enabled: bool, + pub last_polled_at: Option>, + pub created_at: DateTime, +} + +impl SourceRow { + pub fn into_source(self) -> CoreResult { + Ok(Source { + id: parse_id(&self.id)?, + user_id: parse_id(&self.user_id)?, + kind: SourceKind::parse(&self.kind).map_err(newsfeed_core::error::CoreError::from)?, + name: self.name, + url: self.url, + weight: self.weight, + enabled: self.enabled, + last_polled_at: self.last_polled_at, + created_at: self.created_at, + }) + } +} + +#[derive(FromRow)] +pub struct InterestRow { + pub id: String, + pub user_id: String, + pub label: String, + pub weight: f64, + pub created_at: DateTime, +} + +impl InterestRow { + pub fn into_interest(self) -> CoreResult { + Ok(Interest { + id: parse_id(&self.id)?, + user_id: parse_id(&self.user_id)?, + label: self.label, + weight: self.weight, + created_at: self.created_at, + }) + } +} + +#[derive(FromRow)] +pub struct ItemRow { + pub id: String, + pub user_id: String, + pub source_id: Option, + pub external_id: String, + pub url: Option, + pub title: String, + pub summary: Option, + pub author: Option, + pub tags: String, + pub media: String, + pub published_at: Option>, + pub ingested_at: DateTime, + pub state: String, + pub score: f64, +} + +impl ItemRow { + pub fn into_item(self) -> CoreResult { + let source_id = match self.source_id { + Some(s) => Some(parse_id(&s)?), + None => None, + }; + let tags: Vec = serde_json::from_str(&self.tags).map_err(err::json)?; + let media: Vec = serde_json::from_str(&self.media).map_err(err::json)?; + Ok(ContentItem { + id: parse_id(&self.id)?, + user_id: parse_id(&self.user_id)?, + source_id, + external_id: self.external_id, + url: self.url, + title: self.title, + summary: self.summary, + author: self.author, + tags, + media, + published_at: self.published_at, + ingested_at: self.ingested_at, + state: parse_state(&self.state), + score: self.score, + }) + } +} diff --git a/crates/newsfeed-data/src/store/interests.rs b/crates/newsfeed-data/src/store/interests.rs new file mode 100644 index 0000000..45cf829 --- /dev/null +++ b/crates/newsfeed-data/src/store/interests.rs @@ -0,0 +1,63 @@ +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::InterestStore; +use newsfeed_entities::interest::{Interest, UpsertInterest}; + +use crate::err; +use crate::rows::InterestRow; + +use super::SqliteStore; + +const SELECT: &str = "SELECT id, user_id, label, weight, created_at FROM interests"; + +#[async_trait] +impl InterestStore for SqliteStore { + async fn list_interests(&self, user_id: Uuid) -> CoreResult> { + let rows: Vec = sqlx::query_as(&format!( + "{SELECT} WHERE user_id = ? ORDER BY weight DESC, label ASC" + )) + .bind(user_id.to_string()) + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + rows.into_iter().map(InterestRow::into_interest).collect() + } + + async fn upsert_interest( + &self, + user_id: Uuid, + upsert: &UpsertInterest, + ) -> CoreResult { + let id = Uuid::new_v4(); + let now = Utc::now(); + // On (user_id, label) conflict, update the weight in place and return the row. + let row: InterestRow = sqlx::query_as(&format!( + "INSERT INTO interests (id, user_id, label, weight, created_at) VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT(user_id, label) DO UPDATE SET weight = excluded.weight \ + RETURNING {}", + "id, user_id, label, weight, created_at" + )) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(upsert.label.trim()) + .bind(upsert.weight) + .bind(now) + .fetch_one(&self.pool) + .await + .map_err(err::map)?; + row.into_interest() + } + + async fn delete_interest(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> { + sqlx::query("DELETE FROM interests WHERE id = ? AND user_id = ?") + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } +} diff --git a/crates/newsfeed-data/src/store/items.rs b/crates/newsfeed-data/src/store/items.rs new file mode 100644 index 0000000..19aff36 --- /dev/null +++ b/crates/newsfeed-data/src/store/items.rs @@ -0,0 +1,206 @@ +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use newsfeed_core::error::{CoreError, CoreResult}; +use newsfeed_core::ports::{ItemStore, NewCandidate}; +use newsfeed_entities::item::{ContentItem, ItemState}; + +use crate::err; +use crate::rows::ItemRow; + +use super::SqliteStore; + +const COLS: &str = "id, user_id, source_id, external_id, url, title, summary, author, tags, media, published_at, ingested_at, state, score"; + +#[async_trait] +impl ItemStore for SqliteStore { + async fn upsert_candidate(&self, candidate: &NewCandidate) -> CoreResult<(ContentItem, bool)> { + let id = Uuid::new_v4(); + let now = Utc::now(); + let tags = serde_json::to_string(&candidate.tags).map_err(err::json)?; + let media = serde_json::to_string(&candidate.media).map_err(err::json)?; + + // Insert as a candidate; on (user_id, external_id) conflict do nothing. RETURNING + // yields a row only when a new row was actually inserted. + let inserted: Option = sqlx::query_as(&format!( + "INSERT INTO items ({COLS}) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'candidate', 0.0) \ + ON CONFLICT(user_id, external_id) DO NOTHING \ + RETURNING {COLS}" + )) + .bind(id.to_string()) + .bind(candidate.user_id.to_string()) + .bind(candidate.source_id.map(|s| s.to_string())) + .bind(&candidate.external_id) + .bind(&candidate.url) + .bind(&candidate.title) + .bind(&candidate.summary) + .bind(&candidate.author) + .bind(tags) + .bind(media) + .bind(candidate.published_at) + .bind(now) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + + if let Some(row) = inserted { + return Ok((row.into_item()?, true)); + } + + // Conflict: return the pre-existing item. + let existing: ItemRow = sqlx::query_as(&format!( + "SELECT {COLS} FROM items WHERE user_id = ? AND external_id = ?" + )) + .bind(candidate.user_id.to_string()) + .bind(&candidate.external_id) + .fetch_one(&self.pool) + .await + .map_err(err::map)?; + Ok((existing.into_item()?, false)) + } + + async fn get_item(&self, user_id: Uuid, id: Uuid) -> CoreResult> { + let row: Option = sqlx::query_as(&format!( + "SELECT {COLS} FROM items WHERE id = ? AND user_id = ?" + )) + .bind(id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(ItemRow::into_item).transpose() + } + + async fn set_item_state(&self, user_id: Uuid, id: Uuid, state: ItemState) -> CoreResult<()> { + let res = sqlx::query("UPDATE items SET state = ? WHERE id = ? AND user_id = ?") + .bind(state.as_str()) + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + if res.rows_affected() == 0 { + return Err(CoreError::NotFound); + } + Ok(()) + } + + async fn update_score(&self, item_id: Uuid, score: f64) -> CoreResult<()> { + sqlx::query("UPDATE items SET score = ? WHERE id = ?") + .bind(score) + .bind(item_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } + + async fn feed_page( + &self, + user_id: Uuid, + limit: i64, + cursor: Option<&str>, + include_saved: bool, + ) -> CoreResult> { + let states: &[&str] = if include_saved { + &["feed", "saved"] + } else { + &["feed"] + }; + let placeholders = states.iter().map(|_| "?").collect::>().join(", "); + + // Keyset pagination over (score DESC, id DESC). + let (cursor_clause, cursor_score, cursor_id) = match cursor { + Some(c) => { + let (score, id) = parse_cursor(c)?; + ( + " AND (score < ? OR (score = ? AND id < ?))", + Some(score), + Some(id), + ) + } + None => ("", None, None), + }; + + let sql = format!( + "SELECT {COLS} FROM items WHERE user_id = ? AND state IN ({placeholders}){cursor_clause} \ + ORDER BY score DESC, id DESC LIMIT ?" + ); + + let mut q = sqlx::query_as::<_, ItemRow>(&sql).bind(user_id.to_string()); + for s in states { + q = q.bind(*s); + } + if let (Some(score), Some(id)) = (cursor_score, cursor_id) { + q = q.bind(score).bind(score).bind(id); + } + q = q.bind(limit); + + let rows = q.fetch_all(&self.pool).await.map_err(err::map)?; + rows.into_iter().map(ItemRow::into_item).collect() + } + + async fn items_for_scoring(&self, user_id: Uuid, limit: i64) -> CoreResult> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS} FROM items WHERE user_id = ? AND state IN ('candidate', 'feed') \ + ORDER BY ingested_at DESC LIMIT ?" + )) + .bind(user_id.to_string()) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + rows.into_iter().map(ItemRow::into_item).collect() + } + + async fn user_ids_with_sources(&self) -> CoreResult> { + let ids: Vec<(String,)> = sqlx::query_as("SELECT DISTINCT user_id FROM sources") + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + ids.into_iter() + .map(|(s,)| Uuid::parse_str(&s).map_err(err::uuid)) + .collect() + } + + async fn record_signal( + &self, + user_id: Uuid, + item_id: Uuid, + action: newsfeed_entities::feed::SignalAction, + ) -> CoreResult<()> { + let action = match action { + newsfeed_entities::feed::SignalAction::View => "view", + newsfeed_entities::feed::SignalAction::Click => "click", + newsfeed_entities::feed::SignalAction::Save => "save", + newsfeed_entities::feed::SignalAction::Dismiss => "dismiss", + }; + sqlx::query( + "INSERT INTO signals (id, user_id, item_id, action, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(user_id.to_string()) + .bind(item_id.to_string()) + .bind(action) + .bind(Utc::now()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } +} + +/// Cursor is `":"`; the id disambiguates equal scores. +fn parse_cursor(c: &str) -> CoreResult<(f64, String)> { + let (score, id) = c + .split_once(':') + .ok_or_else(|| CoreError::Storage("malformed feed cursor".into()))?; + let score: f64 = score + .parse() + .map_err(|_| CoreError::Storage("malformed feed cursor score".into()))?; + // Validate the id is a uuid but keep the string form for binding. + Uuid::parse_str(id).map_err(err::uuid)?; + Ok((score, id.to_string())) +} diff --git a/crates/newsfeed-data/src/store/mod.rs b/crates/newsfeed-data/src/store/mod.rs new file mode 100644 index 0000000..bb8e0b6 --- /dev/null +++ b/crates/newsfeed-data/src/store/mod.rs @@ -0,0 +1,33 @@ +//! The concrete [`SqliteStore`] and its per-domain port implementations. + +mod interests; +mod items; +mod sessions; +mod sources; +mod tokens; +mod users; + +use sqlx::sqlite::SqlitePool; + +/// SQLite-backed adapter implementing all of the `newsfeed-core` ports. +#[derive(Clone)] +pub struct SqliteStore { + pub(crate) pool: SqlitePool, +} + +impl SqliteStore { + /// Wrap an existing pool. Prefer [`crate::connect`] which also runs migrations. + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + /// Access the underlying pool (tests, health checks). + pub fn pool(&self) -> &SqlitePool { + &self.pool + } + + /// Cheap readiness check: `true` when the database answers a trivial query. + pub async fn ping(&self) -> bool { + sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + } +} diff --git a/crates/newsfeed-data/src/store/sessions.rs b/crates/newsfeed-data/src/store/sessions.rs new file mode 100644 index 0000000..03d0aa7 --- /dev/null +++ b/crates/newsfeed-data/src/store/sessions.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::SessionStore; +use newsfeed_entities::auth::Session; + +use crate::err; +use crate::rows::SessionRow; + +use super::SqliteStore; + +#[async_trait] +impl SessionStore for SqliteStore { + async fn create_session( + &self, + user_id: Uuid, + token_hash: &str, + expires_at: DateTime, + ) -> CoreResult { + let id = Uuid::new_v4(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(token_hash) + .bind(now) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(err::map)?; + + Ok(Session { + id, + user_id, + created_at: now, + expires_at, + }) + } + + async fn find_session(&self, token_hash: &str) -> CoreResult> { + let row: Option = sqlx::query_as( + "SELECT id, user_id, created_at, expires_at FROM sessions \ + WHERE token_hash = ? AND expires_at > ?", + ) + .bind(token_hash) + .bind(Utc::now()) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(SessionRow::into_session).transpose() + } + + async fn delete_session(&self, token_hash: &str) -> CoreResult<()> { + sqlx::query("DELETE FROM sessions WHERE token_hash = ?") + .bind(token_hash) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } +} diff --git a/crates/newsfeed-data/src/store/sources.rs b/crates/newsfeed-data/src/store/sources.rs new file mode 100644 index 0000000..45e9bac --- /dev/null +++ b/crates/newsfeed-data/src/store/sources.rs @@ -0,0 +1,113 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::SourceStore; +use newsfeed_entities::source::{NewSource, Source}; + +use crate::err; +use crate::rows::SourceRow; + +use super::SqliteStore; + +const SELECT: &str = + "SELECT id, user_id, kind, name, url, weight, enabled, last_polled_at, created_at FROM sources"; + +#[async_trait] +impl SourceStore for SqliteStore { + async fn create_source(&self, user_id: Uuid, new: &NewSource) -> CoreResult { + let id = Uuid::new_v4(); + let now = Utc::now(); + let weight = new.weight.unwrap_or(1.0); + sqlx::query( + "INSERT INTO sources (id, user_id, kind, name, url, weight, enabled, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, 1, ?)", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(new.kind.as_str()) + .bind(&new.name) + .bind(&new.url) + .bind(weight) + .bind(now) + .execute(&self.pool) + .await + .map_err(err::map)?; + + Ok(Source { + id, + user_id, + kind: new.kind, + name: new.name.clone(), + url: new.url.clone(), + weight, + enabled: true, + last_polled_at: None, + created_at: now, + }) + } + + async fn list_sources(&self, user_id: Uuid) -> CoreResult> { + let rows: Vec = sqlx::query_as(&format!( + "{SELECT} WHERE user_id = ? ORDER BY created_at DESC" + )) + .bind(user_id.to_string()) + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + rows.into_iter().map(SourceRow::into_source).collect() + } + + async fn find_source_by_name(&self, user_id: Uuid, name: &str) -> CoreResult> { + let row: Option = + sqlx::query_as(&format!("{SELECT} WHERE user_id = ? AND name = ?")) + .bind(user_id.to_string()) + .bind(name) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(SourceRow::into_source).transpose() + } + + async fn delete_source(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> { + sqlx::query("DELETE FROM sources WHERE id = ? AND user_id = ?") + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } + + async fn due_rss_sources( + &self, + now: DateTime, + min_interval_secs: i64, + limit: i64, + ) -> CoreResult> { + // Due when never polled, or last poll older than the minimum interval. + let cutoff = now - chrono::Duration::seconds(min_interval_secs); + let rows: Vec = sqlx::query_as(&format!( + "{SELECT} WHERE kind = 'rss' AND enabled = 1 \ + AND (last_polled_at IS NULL OR last_polled_at < ?) \ + ORDER BY last_polled_at ASC NULLS FIRST LIMIT ?" + )) + .bind(cutoff) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + rows.into_iter().map(SourceRow::into_source).collect() + } + + async fn mark_polled(&self, source_id: Uuid, at: DateTime) -> CoreResult<()> { + sqlx::query("UPDATE sources SET last_polled_at = ? WHERE id = ?") + .bind(at) + .bind(source_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } +} diff --git a/crates/newsfeed-data/src/store/tokens.rs b/crates/newsfeed-data/src/store/tokens.rs new file mode 100644 index 0000000..f73ec34 --- /dev/null +++ b/crates/newsfeed-data/src/store/tokens.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::TokenStore; +use newsfeed_entities::auth::ApiTokenInfo; + +use crate::err; +use crate::rows::ApiTokenRow; + +use super::SqliteStore; + +const SELECT: &str = + "SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at FROM api_tokens"; + +#[async_trait] +impl TokenStore for SqliteStore { + async fn create_token( + &self, + user_id: Uuid, + name: &str, + prefix: &str, + token_hash: &str, + ) -> CoreResult { + let id = Uuid::new_v4(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO api_tokens (id, user_id, name, prefix, token_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(name) + .bind(prefix) + .bind(token_hash) + .bind(now) + .execute(&self.pool) + .await + .map_err(err::map)?; + + Ok(ApiTokenInfo { + id, + user_id, + name: name.to_string(), + prefix: prefix.to_string(), + created_at: now, + last_used_at: None, + revoked_at: None, + }) + } + + async fn list_tokens(&self, user_id: Uuid) -> CoreResult> { + let rows: Vec = sqlx::query_as(&format!( + "{SELECT} WHERE user_id = ? ORDER BY created_at DESC" + )) + .bind(user_id.to_string()) + .fetch_all(&self.pool) + .await + .map_err(err::map)?; + rows.into_iter().map(ApiTokenRow::into_info).collect() + } + + async fn authenticate_token(&self, token_hash: &str) -> CoreResult> { + let now = Utc::now(); + // Record use and fetch in one round trip via RETURNING (SQLite ≥ 3.35). + let row: Option = sqlx::query_as(&format!( + "UPDATE api_tokens SET last_used_at = ? \ + WHERE token_hash = ? AND revoked_at IS NULL \ + RETURNING {}", + "id, user_id, name, prefix, created_at, last_used_at, revoked_at" + )) + .bind(now) + .bind(token_hash) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(ApiTokenRow::into_info).transpose() + } + + async fn revoke_token(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> { + sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL") + .bind(Utc::now()) + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&self.pool) + .await + .map_err(err::map)?; + Ok(()) + } +} diff --git a/crates/newsfeed-data/src/store/users.rs b/crates/newsfeed-data/src/store/users.rs new file mode 100644 index 0000000..b9347f5 --- /dev/null +++ b/crates/newsfeed-data/src/store/users.rs @@ -0,0 +1,67 @@ +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use newsfeed_core::error::CoreResult; +use newsfeed_core::ports::{StoredCredential, UserStore}; +use newsfeed_entities::user::User; + +use crate::err; +use crate::rows::UserRow; + +use super::SqliteStore; + +#[async_trait] +impl UserStore for SqliteStore { + async fn create_user( + &self, + username: &str, + email: &str, + password_hash: &str, + ) -> CoreResult { + let id = Uuid::new_v4(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO users (id, username, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(username) + .bind(email) + .bind(password_hash) + .bind(now) + .execute(&self.pool) + .await + .map_err(err::map)?; + + Ok(User { + id, + username: username.to_string(), + email: email.to_string(), + created_at: now, + }) + } + + async fn get_user(&self, id: Uuid) -> CoreResult> { + let row: Option = sqlx::query_as( + "SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(UserRow::into_user).transpose() + } + + async fn find_credential(&self, identifier: &str) -> CoreResult> { + let row: Option = sqlx::query_as( + "SELECT id, username, email, password_hash, created_at FROM users \ + WHERE username = ? OR email = ?", + ) + .bind(identifier) + .bind(identifier) + .fetch_optional(&self.pool) + .await + .map_err(err::map)?; + row.map(UserRow::into_credential).transpose() + } +} diff --git a/crates/newsfeed-entities/Cargo.toml b/crates/newsfeed-entities/Cargo.toml new file mode 100644 index 0000000..129d00c --- /dev/null +++ b/crates/newsfeed-entities/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "newsfeed-entities" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Domain types, DTOs and error enums for newsfeed. No I/O." + +[dependencies] +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +ts-rs.workspace = true diff --git a/crates/newsfeed-entities/src/auth.rs b/crates/newsfeed-entities/src/auth.rs new file mode 100644 index 0000000..81f3451 --- /dev/null +++ b/crates/newsfeed-entities/src/auth.rs @@ -0,0 +1,61 @@ +//! Session and API-token DTOs. +//! +//! Humans authenticate with an opaque session cookie; agentic and algorithmic sources +//! authenticate with per-user bearer API tokens. In both cases only a hash of the +//! secret is ever persisted — see `newsfeed-core::auth`. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/// An authenticated browser session, keyed by the hash of an opaque cookie value. +#[derive(Debug, Clone)] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// Public metadata about an API token. The secret itself is shown exactly once, at +/// creation, and is never retrievable afterwards. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ApiTokenInfo { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + /// Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token. + pub prefix: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +/// Request to mint a new API token. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct CreateApiToken { + pub name: String, +} + +/// Response returned once when a token is created. `secret` is the full bearer token +/// (`nf__`) and is never persisted in the clear. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +pub struct CreatedApiToken { + #[serde(flatten)] + pub info: ApiTokenInfo, + /// The full secret. Present only in this response. + pub secret: String, +} + +/// The current authenticated principal, returned by `GET /v1/auth/me`. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +pub struct Me { + pub id: Uuid, + pub username: String, + pub email: String, +} diff --git a/crates/newsfeed-entities/src/error.rs b/crates/newsfeed-entities/src/error.rs new file mode 100644 index 0000000..dfe6c0b --- /dev/null +++ b/crates/newsfeed-entities/src/error.rs @@ -0,0 +1,30 @@ +//! Domain-level error type. Validation and invariant failures live here; I/O errors +//! belong to the crates that perform the I/O (`newsfeed-data`, the binaries). + +use thiserror::Error; + +/// Errors produced while constructing or validating domain values. +#[derive(Debug, Error)] +pub enum Error { + /// A user-supplied value failed validation (empty, malformed, out of range). + #[error("invalid {field}: {reason}")] + Invalid { + /// The field that failed validation. + field: &'static str, + /// Why it failed. + reason: String, + }, +} + +impl Error { + /// Convenience constructor for [`Error::Invalid`]. + pub fn invalid(field: &'static str, reason: impl Into) -> Self { + Self::Invalid { + field, + reason: reason.into(), + } + } +} + +/// Result alias for domain operations. +pub type Result = std::result::Result; diff --git a/crates/newsfeed-entities/src/feed.rs b/crates/newsfeed-entities/src/feed.rs new file mode 100644 index 0000000..62d8d2e --- /dev/null +++ b/crates/newsfeed-entities/src/feed.rs @@ -0,0 +1,54 @@ +//! Feed query/response DTOs and interaction signals. Signals are the feedback loop: +//! what the user does with an item feeds back into future ranking — but only ever +//! according to weights the user themselves controls. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::item::ContentItem; + +/// Query parameters for `GET /v1/feed`. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct FeedQuery { + /// Max items to return. Defaults applied server-side. + pub limit: Option, + /// Opaque cursor for pagination (score+id of the last item seen). + pub cursor: Option, + /// When true, include saved items; otherwise only live feed items. + #[serde(default)] + pub include_saved: bool, +} + +/// A page of ranked feed items. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +pub struct FeedPage { + pub items: Vec, + /// Cursor to pass back for the next page, or `None` at the end. + pub next_cursor: Option, +} + +/// The kinds of interaction the user can have with an item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum SignalAction { + /// The item scrolled into view. + View, + /// The user opened/clicked through. + Click, + /// The user saved it. + Save, + /// The user dismissed it. + Dismiss, +} + +/// A recorded interaction, posted to `POST /v1/feed/signals`. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct Signal { + pub item_id: Uuid, + pub action: SignalAction, +} diff --git a/crates/newsfeed-entities/src/interest.rs b/crates/newsfeed-entities/src/interest.rs new file mode 100644 index 0000000..959b2b3 --- /dev/null +++ b/crates/newsfeed-entities/src/interest.rs @@ -0,0 +1,45 @@ +//! Interests: the user-controlled weightings that drive ranking. A user tells the system +//! what they care about and how much; the ranker (see `newsfeed-core::ranking`) uses +//! these weights — and nothing else's opinion — to score their feed. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::error::{Error, Result}; + +/// A weighted topic of interest belonging to a single user. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Interest { + pub id: Uuid, + pub user_id: Uuid, + /// Free-text label matched (case-insensitively) against item title/summary/tags. + pub label: String, + /// How strongly this interest pulls matching items up the feed, `-1.0..=1.0`. + /// Negative weights actively bury matching content. + pub weight: f64, + pub created_at: DateTime, +} + +/// Create or update an interest. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct UpsertInterest { + pub label: String, + pub weight: f64, +} + +impl UpsertInterest { + /// Validate an interest upsert. + pub fn validate(&self) -> Result<()> { + if self.label.trim().is_empty() { + return Err(Error::invalid("label", "must not be empty")); + } + if !(-1.0..=1.0).contains(&self.weight) { + return Err(Error::invalid("weight", "must be within -1.0..=1.0")); + } + Ok(()) + } +} diff --git a/crates/newsfeed-entities/src/item.rs b/crates/newsfeed-entities/src/item.rs new file mode 100644 index 0000000..92b0ed3 --- /dev/null +++ b/crates/newsfeed-entities/src/item.rs @@ -0,0 +1,89 @@ +//! Content items: the units that flow through curation into a user's feed. An item +//! enters as a *candidate* (either polled by the worker or pushed to the ingest +//! endpoint), gets scored, and then lives in the feed until the user acts on it. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/// Lifecycle state of a content item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum ItemState { + /// Newly ingested, awaiting scoring/curation. + Candidate, + /// Scored and live in the feed. + Feed, + /// The user explicitly saved/bookmarked it. + Saved, + /// The user dismissed it; kept for negative signal, hidden from the feed. + Dismissed, +} + +impl ItemState { + /// Stable string form used in the database and on the wire. + pub fn as_str(self) -> &'static str { + match self { + ItemState::Candidate => "candidate", + ItemState::Feed => "feed", + ItemState::Saved => "saved", + ItemState::Dismissed => "dismissed", + } + } +} + +/// A media attachment (image/video/audio) associated with an item. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Media { + pub kind: String, + pub url: String, + pub width: Option, + pub height: Option, +} + +/// A content item owned by a user. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ContentItem { + pub id: Uuid, + pub user_id: Uuid, + pub source_id: Option, + /// De-duplication key from the origin (feed guid, tweet id, URL, …). + pub external_id: String, + pub url: Option, + pub title: String, + pub summary: Option, + pub author: Option, + pub tags: Vec, + pub media: Vec, + pub published_at: Option>, + pub ingested_at: DateTime, + pub state: ItemState, + /// Cached ranking score; recomputed by the ranker as weights/signals change. + pub score: f64, +} + +/// A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic +/// source. The authenticated API token determines the owning user; `source` names the +/// producer for attribution and per-source weighting. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct CandidateSubmission { + /// Stable de-dup key from the producer. Re-submitting the same key is a no-op. + pub external_id: String, + pub url: Option, + pub title: String, + pub summary: Option, + pub author: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub media: Vec, + pub published_at: Option>, + /// Optional name of the source that produced this candidate. If it matches an + /// existing agentic source for the user it is linked; otherwise ingested unlinked. + pub source: Option, +} diff --git a/crates/newsfeed-entities/src/lib.rs b/crates/newsfeed-entities/src/lib.rs new file mode 100644 index 0000000..336fe96 --- /dev/null +++ b/crates/newsfeed-entities/src/lib.rs @@ -0,0 +1,22 @@ +//! Domain types, DTOs and error enums shared across the newsfeed workspace. +//! +//! This crate is intentionally free of I/O and async-runtime dependencies. Everything +//! downstream (`newsfeed-core`, `newsfeed-data`, the binaries and — via generated +//! TypeScript bindings — the web frontend) depends on these types. +//! +//! Wire types derive [`ts_rs::TS`] with `#[ts(export)]` so `cargo test -p +//! newsfeed-entities` regenerates the frontend bindings under `web/src/api/bindings/` +//! (path set by `TS_RS_EXPORT_DIR` in `.cargo/config.toml`). + +pub mod auth; +pub mod error; +pub mod feed; +pub mod interest; +pub mod item; +pub mod source; +pub mod user; + +pub use error::{Error, Result}; + +/// Newtype over a [`uuid::Uuid`] identifier, re-exported for convenience. +pub type Id = uuid::Uuid; diff --git a/crates/newsfeed-entities/src/source.rs b/crates/newsfeed-entities/src/source.rs new file mode 100644 index 0000000..87d8354 --- /dev/null +++ b/crates/newsfeed-entities/src/source.rs @@ -0,0 +1,92 @@ +//! Content sources. A source is where candidate items come from — an RSS/Atom feed the +//! worker polls, or an agentic/external producer that pushes candidates to the ingest +//! endpoint under a user's API token. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::error::{Error, Result}; + +/// How a source produces candidates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum SourceKind { + /// An RSS/Atom feed the worker polls on a schedule. + Rss, + /// A producer that pushes candidates to `POST /v1/ingest/candidates` + /// (agentic workloads, scrapers, bespoke algorithms). + Agentic, +} + +impl SourceKind { + /// Stable string form used in the database and on the wire. + pub fn as_str(self) -> &'static str { + match self { + SourceKind::Rss => "rss", + SourceKind::Agentic => "agentic", + } + } + + /// Parse from the database/wire representation. + pub fn parse(s: &str) -> Result { + match s { + "rss" => Ok(SourceKind::Rss), + "agentic" => Ok(SourceKind::Agentic), + other => Err(Error::invalid( + "kind", + format!("unknown source kind: {other}"), + )), + } + } +} + +/// A user's configured content source. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Source { + pub id: Uuid, + pub user_id: Uuid, + pub kind: SourceKind, + pub name: String, + /// Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources. + pub url: Option, + /// Baseline weight applied to every item from this source, `0.0..=1.0`. + pub weight: f64, + pub enabled: bool, + /// When the worker last polled this source (rss only). + pub last_polled_at: Option>, + pub created_at: DateTime, +} + +/// Request to create a source. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct NewSource { + pub kind: SourceKind, + pub name: String, + pub url: Option, + /// Defaults to `1.0` when omitted. + #[serde(default)] + pub weight: Option, +} + +impl NewSource { + /// Validate a create-source request. + pub fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::invalid("name", "must not be empty")); + } + if self.kind == SourceKind::Rss && self.url.as_deref().unwrap_or("").is_empty() { + return Err(Error::invalid("url", "rss sources require a url")); + } + if let Some(w) = self.weight { + if !(0.0..=1.0).contains(&w) { + return Err(Error::invalid("weight", "must be within 0.0..=1.0")); + } + } + Ok(()) + } +} diff --git a/crates/newsfeed-entities/src/user.rs b/crates/newsfeed-entities/src/user.rs new file mode 100644 index 0000000..d526c7b --- /dev/null +++ b/crates/newsfeed-entities/src/user.rs @@ -0,0 +1,54 @@ +//! User accounts. Each user is fully in control of their own feed weightings. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::error::{Error, Result}; + +/// A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`; +/// it is deliberately absent from this DTO so it can't be serialised to a client. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct User { + pub id: Uuid, + pub username: String, + pub email: String, + pub created_at: DateTime, +} + +/// Registration request from the sign-up form. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct RegisterRequest { + pub username: String, + pub email: String, + pub password: String, +} + +impl RegisterRequest { + /// Validate the shape of a registration request before it reaches the store. + /// Uniqueness is enforced by the database, not here. + pub fn validate(&self) -> Result<()> { + if self.username.trim().len() < 3 { + return Err(Error::invalid("username", "must be at least 3 characters")); + } + if !self.email.contains('@') { + return Err(Error::invalid("email", "must contain '@'")); + } + if self.password.len() < 8 { + return Err(Error::invalid("password", "must be at least 8 characters")); + } + Ok(()) + } +} + +/// Login request. +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct LoginRequest { + /// Username or email. + pub identifier: String, + pub password: String, +} diff --git a/crates/newsfeed-worker/Cargo.toml b/crates/newsfeed-worker/Cargo.toml new file mode 100644 index 0000000..688b71b --- /dev/null +++ b/crates/newsfeed-worker/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "newsfeed-worker" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Algorithmic content-sourcing worker for newsfeed (RSS/Atom polling)." + +[[bin]] +name = "newsfeed-worker" +path = "src/main.rs" + +[dependencies] +newsfeed-entities.workspace = true +newsfeed-core.workspace = true +newsfeed-data.workspace = true + +tokio.workspace = true +reqwest.workspace = true +feed-rs.workspace = true + +serde.workspace = true +chrono.workspace = true +uuid.workspace = true + +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +figment.workspace = true +clap.workspace = true diff --git a/crates/newsfeed-worker/src/config.rs b/crates/newsfeed-worker/src/config.rs new file mode 100644 index 0000000..b628b3f --- /dev/null +++ b/crates/newsfeed-worker/src/config.rs @@ -0,0 +1,55 @@ +//! Worker configuration (figment-layered: defaults → TOML → `NEWSFEED_*` env). + +use std::path::{Path, PathBuf}; + +use figment::Figment; +use figment::providers::{Env, Format, Serialized, Toml}; +use serde::{Deserialize, Serialize}; + +/// Worker configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// SQLite database file, shared with the api on the same host. + pub database_path: PathBuf, + /// SQLite pool size for the worker. + pub max_db_connections: u32, + /// Seconds to sleep between poll cycles. + pub tick_secs: u64, + /// Minimum seconds between polls of the same RSS source. + pub source_min_interval_secs: i64, + /// Max sources polled per cycle. + pub batch: i64, + /// Max items rescored per user per cycle. + pub rescore_limit: i64, + /// HTTP request timeout when fetching feeds, in seconds. + pub http_timeout_secs: u64, + /// User-Agent sent when fetching feeds. + pub user_agent: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + database_path: PathBuf::from("/var/lib/newsfeed/newsfeed.db"), + max_db_connections: 2, + tick_secs: 60, + source_min_interval_secs: 900, + batch: 20, + rescore_limit: 500, + http_timeout_secs: 20, + user_agent: concat!("newsfeed-worker/", env!("CARGO_PKG_VERSION")).to_string(), + } + } +} + +impl Config { + /// Load configuration, layering an optional TOML file and the environment over the + /// defaults. + pub fn load(file: Option<&Path>) -> anyhow::Result { + let mut fig = Figment::from(Serialized::defaults(Config::default())); + if let Some(path) = file { + fig = fig.merge(Toml::file(path)); + } + Ok(fig.merge(Env::prefixed("NEWSFEED_")).extract()?) + } +} diff --git a/crates/newsfeed-worker/src/main.rs b/crates/newsfeed-worker/src/main.rs new file mode 100644 index 0000000..3917405 --- /dev/null +++ b/crates/newsfeed-worker/src/main.rs @@ -0,0 +1,168 @@ +//! newsfeed algorithmic-sourcing worker. +//! +//! Co-located with the api on the same host (they share one SQLite file). Each cycle it +//! polls due RSS/Atom sources, normalises and scores new entries into their owners' +//! feeds, then rescores existing items so weight changes re-rank the feed. Idempotent: +//! re-seeing an item's `external_id` is a no-op. + +mod config; +mod sourcing; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use chrono::Utc; +use clap::Parser; +use reqwest::Client; +use tokio::signal; +use uuid::Uuid; + +use newsfeed_core::ports::{InterestStore, ItemStore, SourceStore}; +use newsfeed_core::{ingest, service}; +use newsfeed_data::SqliteStore; +use newsfeed_entities::interest::Interest; + +use crate::config::Config; +use crate::sourcing::rss; + +#[derive(Parser)] +#[command( + name = "newsfeed-worker", + version, + about = "newsfeed content-sourcing worker" +)] +struct Cli { + /// Path to a TOML config file. Env (`NEWSFEED_*`) overrides file values. + #[arg(long, default_value = "/etc/newsfeed/worker.toml")] + config: PathBuf, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + + let cli = Cli::parse(); + let config_file = cli.config.exists().then_some(cli.config.as_path()); + let cfg = Config::load(config_file).context("loading configuration")?; + tracing::info!(db = %cfg.database_path.display(), tick_secs = cfg.tick_secs, "starting newsfeed-worker"); + + let store = newsfeed_data::connect(&cfg.database_path, cfg.max_db_connections) + .await + .context("opening database")?; + let client = Client::builder() + .user_agent(&cfg.user_agent) + .timeout(Duration::from_secs(cfg.http_timeout_secs)) + .build() + .context("building HTTP client")?; + + let mut ticker = tokio::time::interval(Duration::from_secs(cfg.tick_secs)); + loop { + tokio::select! { + _ = ticker.tick() => { + if let Err(e) = run_cycle(&store, &client, &cfg).await { + tracing::error!(error = %e, "poll cycle failed"); + } + } + _ = shutdown_signal() => { + tracing::info!("shutdown signal received, exiting"); + break; + } + } + } + Ok(()) +} + +/// One poll + rescore cycle. +async fn run_cycle(store: &SqliteStore, client: &Client, cfg: &Config) -> anyhow::Result<()> { + let now = Utc::now(); + let due = store + .due_rss_sources(now, cfg.source_min_interval_secs, cfg.batch) + .await?; + tracing::debug!(count = due.len(), "polling due sources"); + + let mut interests_cache: HashMap> = HashMap::new(); + + for source in due { + let Some(url) = source.url.clone() else { + store.mark_polled(source.id, now).await?; + continue; + }; + + match rss::fetch(client, &url).await { + Ok(entries) => { + let interests = if let Some(cached) = interests_cache.get(&source.user_id) { + cached + } else { + let loaded = store.list_interests(source.user_id).await?; + interests_cache.entry(source.user_id).or_insert(loaded) + }; + + let mut added = 0usize; + for entry in entries { + let candidate = + ingest::candidate_from_feed_entry(source.user_id, source.id, entry); + match ingest::persist_and_rank(store, candidate, source.weight, interests).await + { + Ok(item) if item.ingested_at >= now => added += 1, + Ok(_) => {} + Err(e) => { + tracing::warn!(source = %source.name, error = %e, "failed to persist candidate") + } + } + } + tracing::info!(source = %source.name, added, "polled source"); + } + Err(e) => { + tracing::warn!(source = %source.name, %url, error = %e, "failed to fetch feed") + } + } + + // Mark polled regardless so a persistently-failing feed doesn't monopolise cycles. + store.mark_polled(source.id, now).await?; + } + + // Rescore each active user's feed against current weights. + for user_id in store.user_ids_with_sources().await? { + match service::rescore_user(store, user_id, cfg.rescore_limit).await { + Ok(n) => tracing::debug!(%user_id, rescored = n, "rescored feed"), + Err(e) => tracing::warn!(%user_id, error = %e, "rescore failed"), + } + } + + Ok(()) +} + +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let registry = tracing_subscriber::registry().with(filter); + if std::env::var_os("JOURNAL_STREAM").is_some() { + registry + .with(fmt::layer().json().flatten_event(true)) + .init(); + } else { + registry.with(fmt::layer()).init(); + } +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c().await.expect("install Ctrl-C handler"); + }; + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/crates/newsfeed-worker/src/sourcing/mod.rs b/crates/newsfeed-worker/src/sourcing/mod.rs new file mode 100644 index 0000000..40972b1 --- /dev/null +++ b/crates/newsfeed-worker/src/sourcing/mod.rs @@ -0,0 +1,3 @@ +//! Content sourcing. Currently RSS/Atom polling; other algorithmic sources plug in here. + +pub mod rss; diff --git a/crates/newsfeed-worker/src/sourcing/rss.rs b/crates/newsfeed-worker/src/sourcing/rss.rs new file mode 100644 index 0000000..a262cc7 --- /dev/null +++ b/crates/newsfeed-worker/src/sourcing/rss.rs @@ -0,0 +1,80 @@ +//! Fetch and parse an RSS/Atom feed into backend-agnostic [`FeedEntry`] values. + +use anyhow::Context; +use reqwest::Client; + +use newsfeed_core::ingest::FeedEntry; +use newsfeed_entities::item::Media; + +/// Fetch `url` and parse it, returning one [`FeedEntry`] per feed item. +pub async fn fetch(client: &Client, url: &str) -> anyhow::Result> { + let bytes = client + .get(url) + .send() + .await + .with_context(|| format!("fetching {url}"))? + .error_for_status() + .with_context(|| format!("bad status from {url}"))? + .bytes() + .await + .with_context(|| format!("reading body from {url}"))?; + + let feed = feed_rs::parser::parse(&bytes[..]).with_context(|| format!("parsing feed {url}"))?; + + Ok(feed.entries.into_iter().map(entry_to_feed_entry).collect()) +} + +fn entry_to_feed_entry(entry: feed_rs::model::Entry) -> FeedEntry { + let url = entry.links.first().map(|l| l.href.clone()); + // Prefer the entry's own id; fall back to the link so de-dup still works. + let external_id = if entry.id.is_empty() { + url.clone().unwrap_or_else(|| { + entry + .title + .as_ref() + .map(|t| t.content.clone()) + .unwrap_or_default() + }) + } else { + entry.id.clone() + }; + let title = entry + .title + .map(|t| t.content) + .unwrap_or_else(|| "(untitled)".to_string()); + let summary = entry + .summary + .map(|t| t.content) + .or_else(|| entry.content.and_then(|c| c.body)); + let author = entry.authors.first().map(|p| p.name.clone()); + let tags = entry.categories.into_iter().map(|c| c.term).collect(); + let published_at = entry.published.or(entry.updated); + + let media = entry + .media + .into_iter() + .flat_map(|m| m.content) + .filter_map(|c| { + c.url.map(|u| Media { + kind: c + .content_type + .map(|m| m.to_string()) + .unwrap_or_else(|| "media".to_string()), + url: u.to_string(), + width: c.width, + height: c.height, + }) + }) + .collect(); + + FeedEntry { + external_id, + url, + title, + summary, + author, + tags, + media, + published_at, + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..cf4cfb3 --- /dev/null +++ b/readme.md @@ -0,0 +1,140 @@ +# newsfeed + +A self-hosted, user-controlled news feed. You decide what surfaces and how strongly — +not an opaque engagement algorithm. It's built to replace the feeds you'd otherwise +scroll (Google Discover, YouTube subscriptions, socials) with one you own end-to-end. + +Content arrives two ways: + +- **Algorithmically** — the worker polls RSS/Atom sources you configure. +- **Agentically** — external workloads POST candidates to an ingest endpoint using a + per-user API token. + +Everything is then ranked by **weights you set**: a baseline weight per source and a +signed weight per interest (positive to lift, negative to bury), decayed by recency. The +scoring is a transparent, deterministic function — see `crates/newsfeed-core/src/ranking.rs`. + +Single-user today, multi-user by construction: every row is owned by a `user_id` and each +user is wholly in control of, and isolated within, their own feed. + +## Architecture + +A Rust cargo workspace (per [architecture conventions](https://git.lair.cafe/grenade/architecture)) +plus a Vite/React frontend: + +``` +crates/ + newsfeed-entities domain types + DTOs (no I/O); source of the web's TS bindings + newsfeed-core business logic: ranking, auth primitives, ingest, data-access ports + newsfeed-data SQLite adapters implementing the core ports (sqlx) + newsfeed-api axum REST/JSON daemon (bin) + newsfeed-worker RSS polling + rescoring loop (bin) +web/ Vite + React + SWC + TS SPA (responsive, mobile-first) +asset/ deployment artifacts (systemd, firewalld, nginx, config) +script/ infra-setup.sh (one-time host provisioning) +.gitea/workflows/ CI-driven deploy +``` + +**Shared types.** The web app's API types are generated from the Rust `newsfeed-entities` +crate via `ts-rs` into `web/src/api/bindings/`. Regenerate with `pnpm --dir web gen:types` +(or `cargo test -p newsfeed-entities`). Don't hand-edit the bindings. + +### Deliberate deviations from the house conventions + +- **SQLite, not Postgres** (generic.md §5 defaults to Postgres). Chosen for this app. + Consequence: the **api and worker co-locate** on one host sharing a single DB file; the + worker uses in-process scheduling, not the Postgres `FOR UPDATE SKIP LOCKED` pattern. +- **Runtime sqlx queries, not the `query!` macros** (generic.md §5). SQLite's dynamic + typing makes compile-time query checking low-value and high-friction; runtime queries + keep CI database-free. Rationale is documented at the top of `crates/newsfeed-data/src/lib.rs`. + +## Build + +Prerequisites: a stable Rust toolchain (see `rust-toolchain.toml`), Node ≥ 20, pnpm. + +```sh +# backend +cargo build --workspace +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings + +# frontend +cd web && pnpm install && pnpm build +``` + +## Run locally + +```sh +# 1. API (creates ./data/newsfeed.db, binds 127.0.0.1:8081) +NEWSFEED_DATABASE_PATH=./data/newsfeed.db \ +NEWSFEED_BIND=127.0.0.1:8081 \ +NEWSFEED_COOKIE_SECURE=false \ + cargo run -p newsfeed-api -- --config /nonexistent + +# 2. Worker (same DB file), in another shell +NEWSFEED_DATABASE_PATH=./data/newsfeed.db \ + cargo run -p newsfeed-worker -- --config /nonexistent + +# 3. Frontend dev server (proxies /v1 and /health to :8081) +cd web && pnpm dev +``` + +Config layers **defaults → TOML file (`--config`) → `NEWSFEED_*` env**. Passing a +non-existent `--config` path just uses defaults + env, which is convenient for dev. + +### Try the flow + +```sh +# register + login (cookie jar) +curl -c cj -X POST localhost:8081/v1/auth/register -H content-type:application/json \ + -d '{"username":"me","email":"me@example.com","password":"hunter2hunter2"}' +curl -c cj -X POST localhost:8081/v1/auth/login -H content-type:application/json \ + -d '{"identifier":"me","password":"hunter2hunter2"}' + +# weight an interest, mint an ingest token +curl -b cj -X PUT localhost:8081/v1/interests -H content-type:application/json -d '{"label":"rust","weight":0.9}' +TOKEN=$(curl -b cj -X POST localhost:8081/v1/tokens -H content-type:application/json -d '{"name":"agent"}' | jq -r .secret) + +# an agent pushes a candidate; read the ranked feed +curl -X POST localhost:8081/v1/ingest/candidates -H "authorization: Bearer $TOKEN" \ + -H content-type:application/json -d '{"external_id":"a1","title":"Rust 2.0 released"}' +curl -b cj localhost:8081/v1/feed +``` + +## API surface (`/v1`) + +| Method | Path | Auth | Purpose | +| --- | --- | --- | --- | +| POST | `/auth/register` | — | create account | +| POST | `/auth/login` | — | open session (sets cookie) | +| POST | `/auth/logout` | cookie | end session | +| GET | `/auth/me` | cookie | current user | +| GET | `/feed` | cookie | ranked, keyset-paginated feed | +| POST | `/feed/signals` | cookie | record view/click/save/dismiss | +| GET/POST | `/sources` · DELETE `/sources/{id}` | cookie | manage sources | +| GET/PUT | `/interests` · DELETE `/interests/{id}` | cookie | manage weightings | +| GET/POST | `/tokens` · DELETE `/tokens/{id}` | cookie | manage ingest tokens | +| POST | `/ingest/candidates` | bearer token | submit a candidate (idempotent per `external_id`) | +| GET | `/health` | — | liveness/readiness | + +## Deploy + +CI-driven via Gitea Actions (`.gitea/workflows/deploy.yml`), following +[deployment-gitea-actions.md](https://git.lair.cafe/grenade/architecture). Topology: + +- **api + worker → `slartibartfast.kosherinata.internal`** (share `/var/lib/newsfeed/newsfeed.db`) +- **SPA → `oolon.kosherinata.internal`** — nginx serves `/var/www/newsfeed` and + reverse-proxies `/v1` + `/health` to the API. TLS terminates here; the API speaks plain + HTTP behind firewalld on the mesh. + +One-time per host: `./script/infra-setup.sh` (creates the `gitea_ci` deploy user, scoped +sudoers, the `newsfeed` service account, directories, the `newsfeed.internal` TLS cert + +renewal, and the nginx vhost). Thereafter every push to `main` builds static musl +binaries + the SPA bundle and rsyncs them to the targets. + +Mesh users reach `https://newsfeed.internal`. Public access is at `https://rob.fyi` +(`asset/nginx/newsfeed.public.conf`) — provision its Let's Encrypt cert, then symlink the +vhost into `sites-enabled`. + +For the workspace-wide architectural conventions this project inherits, see the +[architecture repo](https://git.lair.cafe/grenade/architecture). diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d80fdf0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/script/infra-setup.sh b/script/infra-setup.sh new file mode 100755 index 0000000..4a5904a --- /dev/null +++ b/script/infra-setup.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# +# One-time host provisioning for newsfeed's CI-driven deploy (architecture +# deployment-gitea-actions.md §2). Run by the operator from a workstation with full sudo +# and mesh ssh access — NOT by the CI runner. Idempotent; skips unreachable hosts. +# +# It provisions, per host: +# * the `gitea_ci` deploy user + the runner's authorized_key + systemd-journal group +# * a scoped /etc/sudoers.d/newsfeed_gitea_ci drop-in (visudo-verified) +# and per role: +# * API host (slartibartfast): the `newsfeed` service account, /etc/newsfeed + +# /var/lib/newsfeed, SELinux port label for 8081, firewalld service +# * WEB host (oolon): /var/www/newsfeed webroot, the internal `newsfeed.internal` TLS +# cert (lair provisioner) + step@newsfeed renewal, and the nginx vhost +# +# Usage: ./script/infra-setup.sh [--dry-run] +set -euo pipefail + +# --- infra truth (edit here if the topology changes) ------------------------------- +API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worker +WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy +API_PORT="8081" +CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal +RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci +PROVISIONER_PW="${HOME}/.step/secrets/provisioner" +ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem" +CA_URL="https://ca.internal" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2; } + +run_remote() { + # run_remote + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d754048 --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "newsfeed-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write src", + "gen:types": "cd .. && cargo test -p newsfeed-entities" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "globals": "^15.11.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react-swc": "^3.7.0", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..868dd5b --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1857 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.59.0 + version: 5.101.2(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.0 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@eslint/js': + specifier: ^9.13.0 + version: 9.39.4 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react-swc': + specifier: ^3.7.0 + version: 3.11.0(vite@5.4.21) + eslint: + specifier: ^9.13.0 + version: 9.39.4 + eslint-plugin-react-hooks: + specifier: ^5.0.0 + version: 5.2.0(eslint@9.39.4) + eslint-plugin-react-refresh: + specifier: ^0.4.14 + version: 0.4.26(eslint@9.39.4) + globals: + specifier: ^15.11.0 + version: 15.15.0 + prettier: + specifier: ^3.3.3 + version: 3.9.4 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.11.0 + version: 8.63.0(eslint@9.39.4)(typescript@5.9.3) + vite: + specifier: ^5.4.10 + version: 5.4.21 + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.15.43': + resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.43': + resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.43': + resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.43': + resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.43': + resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.43': + resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.43': + resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.43': + resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.43': + resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.43': + resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.43': + resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.43': + resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.43': + resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@3.11.0': + resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@remix-run/router@1.23.3': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@swc/core-darwin-arm64@1.15.43': + optional: true + + '@swc/core-darwin-x64@1.15.43': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.43': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.43': + optional: true + + '@swc/core-linux-arm64-musl@1.15.43': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.43': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-musl@1.15.43': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.43': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.43': + optional: true + + '@swc/core-win32-x64-msvc@1.15.43': + optional: true + + '@swc/core@1.15.43': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.43 + '@swc/core-darwin-x64': 1.15.43 + '@swc/core-linux-arm-gnueabihf': 1.15.43 + '@swc/core-linux-arm64-gnu': 1.15.43 + '@swc/core-linux-arm64-musl': 1.15.43 + '@swc/core-linux-ppc64-gnu': 1.15.43 + '@swc/core-linux-s390x-gnu': 1.15.43 + '@swc/core-linux-x64-gnu': 1.15.43 + '@swc/core-linux-x64-musl': 1.15.43 + '@swc/core-win32-arm64-msvc': 1.15.43 + '@swc/core-win32-ia32-msvc': 1.15.43 + '@swc/core-win32-x64-msvc': 1.15.43 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 18.3.1 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react-swc@3.11.0(vite@5.4.21)': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.27 + '@swc/core': 1.15.43 + vite: 5.4.21 + transitivePeerDependencies: + - '@swc/helpers' + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.4: {} + + punycode@2.3.1: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.4(react@18.3.1) + + react-router@6.30.4(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resolve-from@4.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.63.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..5cd87df --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,42 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { AuthProvider, useAuth } from './lib/auth'; +import { Nav } from './components/Nav'; +import { Login } from './routes/Login'; +import { Feed } from './routes/Feed'; +import { Sources } from './routes/Sources'; +import { Settings } from './routes/Settings'; + +function Shell() { + const { user, loading } = useAuth(); + + if (loading) { + return
Loading…
; + } + + if (!user) { + return ; + } + + return ( +
+
+ ); +} + +export function App() { + return ( + + + + ); +} diff --git a/web/src/api/bindings/ApiTokenInfo.ts b/web/src/api/bindings/ApiTokenInfo.ts new file mode 100644 index 0000000..01e7e83 --- /dev/null +++ b/web/src/api/bindings/ApiTokenInfo.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Public metadata about an API token. The secret itself is shown exactly once, at + * creation, and is never retrievable afterwards. + */ +export type ApiTokenInfo = { id: string, user_id: string, name: string, +/** + * Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token. + */ +prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, }; diff --git a/web/src/api/bindings/CandidateSubmission.ts b/web/src/api/bindings/CandidateSubmission.ts new file mode 100644 index 0000000..f1e8603 --- /dev/null +++ b/web/src/api/bindings/CandidateSubmission.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Media } from "./Media"; + +/** + * A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic + * source. The authenticated API token determines the owning user; `source` names the + * producer for attribution and per-source weighting. + */ +export type CandidateSubmission = { +/** + * Stable de-dup key from the producer. Re-submitting the same key is a no-op. + */ +external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array, media: Array, published_at: string | null, +/** + * Optional name of the source that produced this candidate. If it matches an + * existing agentic source for the user it is linked; otherwise ingested unlinked. + */ +source: string | null, }; diff --git a/web/src/api/bindings/ContentItem.ts b/web/src/api/bindings/ContentItem.ts new file mode 100644 index 0000000..1907d0e --- /dev/null +++ b/web/src/api/bindings/ContentItem.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ItemState } from "./ItemState"; +import type { Media } from "./Media"; + +/** + * A content item owned by a user. + */ +export type ContentItem = { id: string, user_id: string, source_id: string | null, +/** + * De-duplication key from the origin (feed guid, tweet id, URL, …). + */ +external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array, media: Array, published_at: string | null, ingested_at: string, state: ItemState, +/** + * Cached ranking score; recomputed by the ranker as weights/signals change. + */ +score: number, }; diff --git a/web/src/api/bindings/CreateApiToken.ts b/web/src/api/bindings/CreateApiToken.ts new file mode 100644 index 0000000..ff7760b --- /dev/null +++ b/web/src/api/bindings/CreateApiToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to mint a new API token. + */ +export type CreateApiToken = { name: string, }; diff --git a/web/src/api/bindings/CreatedApiToken.ts b/web/src/api/bindings/CreatedApiToken.ts new file mode 100644 index 0000000..c51fbf3 --- /dev/null +++ b/web/src/api/bindings/CreatedApiToken.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response returned once when a token is created. `secret` is the full bearer token + * (`nf__`) and is never persisted in the clear. + */ +export type CreatedApiToken = { +/** + * The full secret. Present only in this response. + */ +secret: string, id: string, user_id: string, name: string, +/** + * Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token. + */ +prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, }; diff --git a/web/src/api/bindings/FeedPage.ts b/web/src/api/bindings/FeedPage.ts new file mode 100644 index 0000000..0fbfc37 --- /dev/null +++ b/web/src/api/bindings/FeedPage.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContentItem } from "./ContentItem"; + +/** + * A page of ranked feed items. + */ +export type FeedPage = { items: Array, +/** + * Cursor to pass back for the next page, or `None` at the end. + */ +next_cursor: string | null, }; diff --git a/web/src/api/bindings/FeedQuery.ts b/web/src/api/bindings/FeedQuery.ts new file mode 100644 index 0000000..8e241cd --- /dev/null +++ b/web/src/api/bindings/FeedQuery.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Query parameters for `GET /v1/feed`. + */ +export type FeedQuery = { +/** + * Max items to return. Defaults applied server-side. + */ +limit: number | null, +/** + * Opaque cursor for pagination (score+id of the last item seen). + */ +cursor: string | null, +/** + * When true, include saved items; otherwise only live feed items. + */ +include_saved: boolean, }; diff --git a/web/src/api/bindings/Interest.ts b/web/src/api/bindings/Interest.ts new file mode 100644 index 0000000..d225936 --- /dev/null +++ b/web/src/api/bindings/Interest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A weighted topic of interest belonging to a single user. + */ +export type Interest = { id: string, user_id: string, +/** + * Free-text label matched (case-insensitively) against item title/summary/tags. + */ +label: string, +/** + * How strongly this interest pulls matching items up the feed, `-1.0..=1.0`. + * Negative weights actively bury matching content. + */ +weight: number, created_at: string, }; diff --git a/web/src/api/bindings/ItemState.ts b/web/src/api/bindings/ItemState.ts new file mode 100644 index 0000000..b95cbe4 --- /dev/null +++ b/web/src/api/bindings/ItemState.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Lifecycle state of a content item. + */ +export type ItemState = "candidate" | "feed" | "saved" | "dismissed"; diff --git a/web/src/api/bindings/LoginRequest.ts b/web/src/api/bindings/LoginRequest.ts new file mode 100644 index 0000000..260aed9 --- /dev/null +++ b/web/src/api/bindings/LoginRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Login request. + */ +export type LoginRequest = { +/** + * Username or email. + */ +identifier: string, password: string, }; diff --git a/web/src/api/bindings/Me.ts b/web/src/api/bindings/Me.ts new file mode 100644 index 0000000..70431ee --- /dev/null +++ b/web/src/api/bindings/Me.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The current authenticated principal, returned by `GET /v1/auth/me`. + */ +export type Me = { id: string, username: string, email: string, }; diff --git a/web/src/api/bindings/Media.ts b/web/src/api/bindings/Media.ts new file mode 100644 index 0000000..d3a25aa --- /dev/null +++ b/web/src/api/bindings/Media.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A media attachment (image/video/audio) associated with an item. + */ +export type Media = { kind: string, url: string, width: number | null, height: number | null, }; diff --git a/web/src/api/bindings/NewSource.ts b/web/src/api/bindings/NewSource.ts new file mode 100644 index 0000000..80bce7e --- /dev/null +++ b/web/src/api/bindings/NewSource.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SourceKind } from "./SourceKind"; + +/** + * Request to create a source. + */ +export type NewSource = { kind: SourceKind, name: string, url: string | null, +/** + * Defaults to `1.0` when omitted. + */ +weight: number | null, }; diff --git a/web/src/api/bindings/RegisterRequest.ts b/web/src/api/bindings/RegisterRequest.ts new file mode 100644 index 0000000..c6aff07 --- /dev/null +++ b/web/src/api/bindings/RegisterRequest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Registration request from the sign-up form. + */ +export type RegisterRequest = { username: string, email: string, password: string, }; diff --git a/web/src/api/bindings/Signal.ts b/web/src/api/bindings/Signal.ts new file mode 100644 index 0000000..1c66950 --- /dev/null +++ b/web/src/api/bindings/Signal.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SignalAction } from "./SignalAction"; + +/** + * A recorded interaction, posted to `POST /v1/feed/signals`. + */ +export type Signal = { item_id: string, action: SignalAction, }; diff --git a/web/src/api/bindings/SignalAction.ts b/web/src/api/bindings/SignalAction.ts new file mode 100644 index 0000000..da5cd99 --- /dev/null +++ b/web/src/api/bindings/SignalAction.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The kinds of interaction the user can have with an item. + */ +export type SignalAction = "view" | "click" | "save" | "dismiss"; diff --git a/web/src/api/bindings/Source.ts b/web/src/api/bindings/Source.ts new file mode 100644 index 0000000..03bcf37 --- /dev/null +++ b/web/src/api/bindings/Source.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SourceKind } from "./SourceKind"; + +/** + * A user's configured content source. + */ +export type Source = { id: string, user_id: string, kind: SourceKind, name: string, +/** + * Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources. + */ +url: string | null, +/** + * Baseline weight applied to every item from this source, `0.0..=1.0`. + */ +weight: number, enabled: boolean, +/** + * When the worker last polled this source (rss only). + */ +last_polled_at: string | null, created_at: string, }; diff --git a/web/src/api/bindings/SourceKind.ts b/web/src/api/bindings/SourceKind.ts new file mode 100644 index 0000000..fb2f9be --- /dev/null +++ b/web/src/api/bindings/SourceKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How a source produces candidates. + */ +export type SourceKind = "rss" | "agentic"; diff --git a/web/src/api/bindings/UpsertInterest.ts b/web/src/api/bindings/UpsertInterest.ts new file mode 100644 index 0000000..b40b944 --- /dev/null +++ b/web/src/api/bindings/UpsertInterest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Create or update an interest. + */ +export type UpsertInterest = { label: string, weight: number, }; diff --git a/web/src/api/bindings/User.ts b/web/src/api/bindings/User.ts new file mode 100644 index 0000000..39a9f53 --- /dev/null +++ b/web/src/api/bindings/User.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`; + * it is deliberately absent from this DTO so it can't be serialised to a client. + */ +export type User = { id: string, username: string, email: string, created_at: string, }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..68cd776 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,84 @@ +// Thin typed client over the newsfeed API. Types are the generated bindings from the +// Rust `newsfeed-entities` crate (see src/api/bindings/, regenerate with `pnpm gen:types`). +// Session auth rides on an HttpOnly cookie, so every request sends credentials. + +import type { Me } from './bindings/Me'; +import type { RegisterRequest } from './bindings/RegisterRequest'; +import type { LoginRequest } from './bindings/LoginRequest'; +import type { ApiTokenInfo } from './bindings/ApiTokenInfo'; +import type { CreateApiToken } from './bindings/CreateApiToken'; +import type { CreatedApiToken } from './bindings/CreatedApiToken'; +import type { Source } from './bindings/Source'; +import type { NewSource } from './bindings/NewSource'; +import type { Interest } from './bindings/Interest'; +import type { UpsertInterest } from './bindings/UpsertInterest'; +import type { FeedPage } from './bindings/FeedPage'; +import type { Signal } from './bindings/Signal'; + +const BASE = import.meta.env.VITE_API_BASE_URL ?? ''; + +/** Error carrying the HTTP status so callers (e.g. 401 → login) can branch on it. */ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method, + credentials: 'include', + headers: body !== undefined ? { 'content-type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + if (data && typeof data.error === 'string') message = data.error; + } catch { + /* non-JSON error body */ + } + throw new ApiError(res.status, message); + } + + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export const api = { + // auth + me: () => request('GET', '/v1/auth/me'), + register: (req: RegisterRequest) => request('POST', '/v1/auth/register', req), + login: (req: LoginRequest) => request('POST', '/v1/auth/login', req), + logout: () => request('POST', '/v1/auth/logout'), + + // feed + feed: (opts: { cursor?: string; includeSaved?: boolean } = {}) => { + const q = new URLSearchParams(); + if (opts.cursor) q.set('cursor', opts.cursor); + if (opts.includeSaved) q.set('include_saved', 'true'); + const qs = q.toString(); + return request('GET', `/v1/feed${qs ? `?${qs}` : ''}`); + }, + signal: (sig: Signal) => request('POST', '/v1/feed/signals', sig), + + // sources + listSources: () => request('GET', '/v1/sources'), + createSource: (s: NewSource) => request('POST', '/v1/sources', s), + deleteSource: (id: string) => request('DELETE', `/v1/sources/${id}`), + + // interests + listInterests: () => request('GET', '/v1/interests'), + upsertInterest: (i: UpsertInterest) => request('PUT', '/v1/interests', i), + deleteInterest: (id: string) => request('DELETE', `/v1/interests/${id}`), + + // api tokens + listTokens: () => request('GET', '/v1/tokens'), + createToken: (t: CreateApiToken) => request('POST', '/v1/tokens', t), + revokeToken: (id: string) => request('DELETE', `/v1/tokens/${id}`), +}; diff --git a/web/src/components/FeedItem.tsx b/web/src/components/FeedItem.tsx new file mode 100644 index 0000000..22b71c1 --- /dev/null +++ b/web/src/components/FeedItem.tsx @@ -0,0 +1,65 @@ +import { api } from '../api/client'; +import type { ContentItem } from '../api/bindings/ContentItem'; +import type { SignalAction } from '../api/bindings/SignalAction'; + +interface Props { + item: ContentItem; + onSignal: (action: SignalAction) => void; +} + +export function FeedItem({ item, onSignal }: Props) { + const image = item.media.find((m) => m.kind.startsWith('image'))?.url; + const published = item.published_at ? new Date(item.published_at).toLocaleDateString() : null; + + const openAndClick = () => { + onSignal('click'); + // Fire-and-forget signal; navigation proceeds regardless. + void api.signal({ item_id: item.id, action: 'click' }).catch(() => {}); + }; + + return ( +
+ {image && ( + + + + )} +
+
+ + {item.score.toFixed(2)} + + {item.author && {item.author}} + {published && {published}} +
+

+ {item.url ? ( + + {item.title} + + ) : ( + item.title + )} +

+ {item.summary &&

{item.summary}

} + {item.tags.length > 0 && ( +
+ {item.tags.map((t) => ( + + {t} + + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx new file mode 100644 index 0000000..a607a90 --- /dev/null +++ b/web/src/components/Nav.tsx @@ -0,0 +1,38 @@ +// Responsive navigation: a bottom tab bar on mobile, a left rail on desktop (driven +// entirely by CSS media queries against .nav). + +import { NavLink } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; + +const links = [ + { to: '/', label: 'Feed', icon: '📰', end: true }, + { to: '/sources', label: 'Sources', icon: '🌐', end: false }, + { to: '/settings', label: 'Settings', icon: '⚙️', end: false }, +]; + +export function Nav() { + const { user, logout } = useAuth(); + return ( + + ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..795cc16 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,438 @@ +:root { + --bg: #0b0d10; + --surface: #14181d; + --surface-2: #1b2027; + --border: #262d36; + --text: #e6e9ee; + --muted: #8a93a0; + --accent: #4c8dff; + --accent-contrast: #ffffff; + --pos: #43c47a; + --neg: #ff6b6b; + --radius: 14px; + --nav-h: 60px; + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f5f6f8; + --surface: #ffffff; + --surface-2: #f0f2f5; + --border: #e2e6eb; + --text: #10151b; + --muted: #62707f; + --accent: #2f6fe0; + color-scheme: light; + } +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + font-size: 16px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + border-radius: 10px; + padding: 0.5rem 0.9rem; + transition: filter 0.15s ease; +} +button:hover:not(:disabled) { + filter: brightness(1.15); +} +button:disabled { + opacity: 0.5; + cursor: default; +} +button.primary { + background: var(--accent); + color: var(--accent-contrast); + border-color: transparent; + font-weight: 600; +} +button.ghost { + background: transparent; +} +button.link { + border: none; + background: none; + color: var(--accent); + padding: 0.3rem; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.55rem 0.7rem; + width: 100%; +} +input[type='range'] { + padding: 0; + accent-color: var(--accent); +} +input[type='checkbox'] { + width: auto; +} +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +/* ---------- layout ---------- */ +.app { + min-height: 100%; + display: flex; + flex-direction: column; +} +.content { + flex: 1; + width: 100%; + max-width: 720px; + margin: 0 auto; + padding: 1rem 1rem calc(var(--nav-h) + env(safe-area-inset-bottom) + 1rem); +} + +.center { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.muted { + color: var(--muted); +} +.small { + font-size: 0.82rem; +} +.mono { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 0.85rem; +} +.error { + color: var(--neg); + font-size: 0.85rem; + margin: 0; +} +.pos { + color: var(--pos); +} +.neg { + color: var(--neg); +} +.empty { + text-align: center; + padding: 2rem 1rem; +} + +.page-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.page-head h1 { + font-size: 1.4rem; + margin: 0; +} +.stack { + display: flex; + flex-direction: column; + gap: 1rem; +} +.row { + display: flex; + gap: 0.6rem; + align-items: flex-end; + flex-wrap: wrap; +} +.grow { + flex: 1; + min-width: 8rem; +} +.toggle { + flex-direction: row; + align-items: center; + gap: 0.4rem; +} + +/* ---------- nav ---------- */ +.nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(var(--nav-h) + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--surface); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + z-index: 10; +} +.nav-brand { + display: none; +} +.nav-links { + display: flex; + flex: 1; + list-style: none; + margin: 0; + padding: 0; +} +.nav-links li { + flex: 1; +} +.nav a, +.nav-logout { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 0.4rem; + width: 100%; + border: none; + background: none; + color: var(--muted); + font-size: 0.7rem; +} +.nav a.active { + color: var(--accent); +} +.nav-icon { + font-size: 1.2rem; +} + +@media (min-width: 860px) { + .app { + flex-direction: row; + } + .nav { + position: sticky; + top: 0; + height: 100dvh; + width: 220px; + flex-direction: column; + align-items: stretch; + border-top: none; + border-right: 1px solid var(--border); + padding: 1.25rem 0.75rem; + gap: 0.25rem; + } + .nav-brand { + display: block; + font-weight: 700; + font-size: 1.25rem; + padding: 0 0.75rem 1rem; + } + .nav-links { + flex-direction: column; + flex: 0; + gap: 0.25rem; + } + .nav a, + .nav-logout { + flex-direction: row; + justify-content: flex-start; + gap: 0.75rem; + padding: 0.7rem 0.75rem; + border-radius: 10px; + font-size: 0.95rem; + } + .nav a.active { + background: var(--surface-2); + } + .nav-logout { + margin-top: auto; + } + .content { + padding-bottom: 2rem; + } +} + +/* ---------- auth ---------- */ +.auth-card { + width: min(380px, 92vw); +} +.brand { + margin: 0; + font-size: 1.8rem; + letter-spacing: -0.02em; +} + +/* ---------- feed ---------- */ +.feed-list { + display: flex; + flex-direction: column; + gap: 1rem; +} +.feed-item { + padding: 0; + overflow: hidden; + gap: 0; +} +.feed-thumb img { + width: 100%; + max-height: 240px; + object-fit: cover; + display: block; +} +.feed-body { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.feed-meta { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.78rem; +} +.score { + font-family: ui-monospace, monospace; + font-weight: 700; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, transparent); + padding: 0.1rem 0.4rem; + border-radius: 6px; +} +.feed-title { + font-size: 1.1rem; + margin: 0; + line-height: 1.3; +} +.feed-summary { + margin: 0; + color: var(--muted); + font-size: 0.92rem; +} +.feed-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} +.more { + display: block; + margin: 1.25rem auto 0; +} + +/* ---------- lists / pills / tags ---------- */ +.list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.list-row-end { + display: flex; + align-items: center; + gap: 0.6rem; +} +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.tag { + font-size: 0.72rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.1rem 0.55rem; + color: var(--muted); +} +.pill { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 6px; + padding: 0.1rem 0.45rem; + margin-right: 0.5rem; +} +.pill-rss { + background: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--accent); +} +.pill-agentic { + background: color-mix(in srgb, var(--pos) 20%, transparent); + color: var(--pos); +} +.pill-revoked { + background: color-mix(in srgb, var(--neg) 20%, transparent); + color: var(--neg); + margin-left: 0.5rem; +} + +/* ---------- token secret ---------- */ +.secret { + background: var(--surface-2); + border: 1px dashed var(--accent); + border-radius: 10px; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.token { + font-family: ui-monospace, monospace; + word-break: break-all; + background: var(--bg); + padding: 0.5rem; + border-radius: 8px; +} diff --git a/web/src/lib/auth.tsx b/web/src/lib/auth.tsx new file mode 100644 index 0000000..32a77da --- /dev/null +++ b/web/src/lib/auth.tsx @@ -0,0 +1,50 @@ +// Auth state derived from the server. `me` is the single source of truth: a successful +// query means an authenticated session, a 401 means logged out. + +import { createContext, useContext, type ReactNode } from 'react'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; + +import { api, ApiError } from '../api/client'; +import type { Me } from '../api/bindings/Me'; + +interface AuthState { + user: Me | null; + loading: boolean; + refresh: () => void; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const qc = useQueryClient(); + const meQuery = useQuery({ + queryKey: ['me'], + queryFn: api.me, + // A 401 is a normal "logged out" answer, not a retryable error. + retry: (_count, err) => !(err instanceof ApiError && err.status === 401), + }); + + const logoutMutation = useMutation({ + mutationFn: api.logout, + onSuccess: () => qc.setQueryData(['me'], null), + }); + + const unauthorized = meQuery.error instanceof ApiError && meQuery.error.status === 401; + + const value: AuthState = { + user: unauthorized ? null : (meQuery.data ?? null), + loading: meQuery.isLoading, + refresh: () => qc.invalidateQueries({ queryKey: ['me'] }), + logout: () => logoutMutation.mutate(), + }; + + return {children}; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..15d3b11 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; + +import { App } from './App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/web/src/routes/Feed.tsx b/web/src/routes/Feed.tsx new file mode 100644 index 0000000..3341ee9 --- /dev/null +++ b/web/src/routes/Feed.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { api } from '../api/client'; +import { FeedItem } from '../components/FeedItem'; +import type { SignalAction } from '../api/bindings/SignalAction'; + +export function Feed() { + const qc = useQueryClient(); + const [includeSaved, setIncludeSaved] = useState(false); + + const query = useInfiniteQuery({ + queryKey: ['feed', { includeSaved }], + queryFn: ({ pageParam }) => api.feed({ cursor: pageParam, includeSaved }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => last.next_cursor ?? undefined, + }); + + const signal = useMutation({ + mutationFn: ({ id, action }: { id: string; action: SignalAction }) => + api.signal({ item_id: id, action }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['feed'] }), + }); + + const items = query.data?.pages.flatMap((p) => p.items) ?? []; + + return ( +
+
+

Feed

+ +
+ + {query.isLoading &&

Loading your feed…

} + {query.isError &&

Couldn’t load the feed.

} + {!query.isLoading && items.length === 0 && ( +

+ Nothing here yet. Add a source, or push candidates to the ingest endpoint with an API token. +

+ )} + +
+ {items.map((item) => ( + signal.mutate({ id: item.id, action })} /> + ))} +
+ + {query.hasNextPage && ( + + )} +
+ ); +} diff --git a/web/src/routes/Login.tsx b/web/src/routes/Login.tsx new file mode 100644 index 0000000..c69bb4e --- /dev/null +++ b/web/src/routes/Login.tsx @@ -0,0 +1,95 @@ +import { useState, type FormEvent } from 'react'; +import { useMutation } from '@tanstack/react-query'; + +import { api, ApiError } from '../api/client'; +import { useAuth } from '../lib/auth'; + +export function Login() { + const { refresh } = useAuth(); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [identifier, setIdentifier] = useState(''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const mutation = useMutation({ + mutationFn: async () => { + if (mode === 'register') { + await api.register({ username, email, password }); + } + await api.login({ + identifier: mode === 'register' ? username : identifier, + password, + }); + }, + onSuccess: refresh, + }); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + mutation.mutate(); + }; + + const error = + mutation.error instanceof ApiError ? mutation.error.message : mutation.error ? 'Something went wrong' : null; + + return ( +
+
+

newsfeed

+

Your feed. Your weights. Your rules.

+ + {mode === 'register' && ( + <> + + + + )} + + {mode === 'login' && ( + + )} + + + + {error &&

{error}

} + + + + +
+
+ ); +} diff --git a/web/src/routes/Settings.tsx b/web/src/routes/Settings.tsx new file mode 100644 index 0000000..2154811 --- /dev/null +++ b/web/src/routes/Settings.tsx @@ -0,0 +1,172 @@ +import { useState, type FormEvent } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { api } from '../api/client'; +import type { CreatedApiToken } from '../api/bindings/CreatedApiToken'; + +function Interests() { + const qc = useQueryClient(); + const interests = useQuery({ queryKey: ['interests'], queryFn: api.listInterests }); + const [label, setLabel] = useState(''); + const [weight, setWeight] = useState(0.5); + + const upsert = useMutation({ + mutationFn: (body: { label: string; weight: number }) => api.upsertInterest(body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }), + }); + const remove = useMutation({ + mutationFn: (id: string) => api.deleteInterest(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }), + }); + + const onAdd = (e: FormEvent) => { + e.preventDefault(); + if (!label.trim()) return; + upsert.mutate({ label: label.trim(), weight }); + setLabel(''); + setWeight(0.5); + }; + + return ( +
+

Interests

+

+ Positive weights pull matching stories up; negative weights bury them. These weights — and only these — decide + your ranking. +

+ +
+ setLabel(e.target.value)} + placeholder="topic, e.g. rust" + /> + setWeight(Number(e.target.value))} + /> + {weight.toFixed(2)} + +
+ +
    + {interests.data?.map((i) => ( +
  • + {i.label} +
    + upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) })} + onTouchEnd={(e) => + upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) }) + } + /> + {i.weight.toFixed(2)} + +
    +
  • + ))} + {interests.data?.length === 0 &&

    No interests yet.

    } +
+
+ ); +} + +function Tokens() { + const qc = useQueryClient(); + const tokens = useQuery({ queryKey: ['tokens'], queryFn: api.listTokens }); + const [name, setName] = useState(''); + const [fresh, setFresh] = useState(null); + + const create = useMutation({ + mutationFn: () => api.createToken({ name: name.trim() }), + onSuccess: (t) => { + setFresh(t); + setName(''); + qc.invalidateQueries({ queryKey: ['tokens'] }); + }, + }); + const revoke = useMutation({ + mutationFn: (id: string) => api.revokeToken(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tokens'] }), + }); + + return ( +
+

API tokens

+

+ Agentic and algorithmic producers POST candidates to /v1/ingest/candidates with a bearer token. + Each token is scoped to your feed. +

+ +
{ + e.preventDefault(); + if (name.trim()) create.mutate(); + }} + > + setName(e.target.value)} placeholder="token name" /> + +
+ + {fresh && ( +
+

+ Copy this now — it won’t be shown again: +

+ {fresh.secret} + +
+ )} + +
    + {tokens.data?.map((t) => ( +
  • +
    + {t.name} {t.prefix}… + {t.revoked_at && revoked} +
    + {!t.revoked_at && ( + + )} +
  • + ))} + {tokens.data?.length === 0 &&

    No tokens yet.

    } +
+
+ ); +} + +export function Settings() { + return ( +
+
+

Settings

+
+
+ + +
+
+ ); +} diff --git a/web/src/routes/Sources.tsx b/web/src/routes/Sources.tsx new file mode 100644 index 0000000..78bdec2 --- /dev/null +++ b/web/src/routes/Sources.tsx @@ -0,0 +1,114 @@ +import { useState, type FormEvent } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { api, ApiError } from '../api/client'; +import type { SourceKind } from '../api/bindings/SourceKind'; + +export function Sources() { + const qc = useQueryClient(); + const sources = useQuery({ queryKey: ['sources'], queryFn: api.listSources }); + + const [kind, setKind] = useState('rss'); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [weight, setWeight] = useState(1); + + const create = useMutation({ + mutationFn: () => api.createSource({ kind, name, url: kind === 'rss' ? url : null, weight }), + onSuccess: () => { + setName(''); + setUrl(''); + setWeight(1); + qc.invalidateQueries({ queryKey: ['sources'] }); + }, + }); + + const remove = useMutation({ + mutationFn: (id: string) => api.deleteSource(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sources'] }), + }); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + create.mutate(); + }; + + const error = create.error instanceof ApiError ? create.error.message : null; + + return ( +
+
+

Sources

+
+ +
+
+ + +
+ + {kind === 'rss' && ( + + )} + + + + {error &&

{error}

} + +
+ +
    + {sources.data?.map((s) => ( +
  • +
    + {s.kind} + {s.name} + {s.url && ( + + {s.url} + + )} +
    +
    + w {s.weight.toFixed(2)} + +
    +
  • + ))} + {sources.data?.length === 0 &&

    No sources yet.

    } +
+
+ ); +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ad04ed5 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..41cbb63 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +// The production API base URL is stamped at build time via VITE_API_BASE_URL (empty in +// production, where nginx serves this SPA and proxies /v1 and /health under one origin). +// In dev we proxy to the local API daemon so the browser stays same-origin. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/v1': 'http://127.0.0.1:8081', + '/health': 'http://127.0.0.1:8081', + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +});