SPEC v0.29: W7 web serving and analytics (D-135 to D-138), APK deploy (D-139)
D-135 /app/ serving from KVITT_WEB_DIR (fallback, caching, CSP, path safety); D-136 /app on the unauthenticated per-IP limit; D-137 repository-root image build with the Node 24 stage and the allowlist .dockerignore; D-138 E-34 trends and E-35 forecast with the rules in new §14.5.7; D-139 publish-apk.sh --deploy. infra/README.md build context; HANDOFF state and numbering (next D-141, v0.31). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+6
-1
@@ -136,6 +136,11 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-129 | 2026-10-21 | Web client in scope (revises §2's "A web client", "CSV or any other export" and, for the web only, the language-picker and layout non-goals). Kvitt gets a web client with the Android app's features except the phone-only ones: the phone pairing flow (S-1's server field, `kvitt://` links), push notifications, offline use and the sync screen (S-16), the in-app update (S-17). In: Home balances, account detail and editor, expense add, edit, delete and detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with D-128's line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about. Extras the app lacks: free-text search across descriptions and notes with amount, date, account, category and payer filters; CSV export of the filtered expense list (format chosen by W2 for Numbers and Excel in a Swedish locale, proposed in its report); keyboard shortcuts `N` new expense, `/` search, `Esc` close, `?` help; a language choice overriding the browser's language. Desktop first, one column with a bottom bar under 720 px; sv and en; light and dark by `prefers-color-scheme`; not a PWA (no service worker, no manifest, no offline). Pages W-1 to W-20 mirror the S-n screens (§15.6). No receipt picture (none in the app). | Owner interview 2026-10-21: the owner and the partner want the household on a computer too, with the bigger screen used for search, export and analytics; the phone-only parts have no meaning in a browser. | SPEC §1, §2, §15.1, §15.6, §15.7, FR-21 |
|
||||
| D-130 | 2026-10-21 | Web access and login (extends D-126). The web client is served by `kvitt-server` under `/app/`. A browser logs in with an invite code exactly like a phone pairs: E-3 `POST /invites/accept` with a `device` invite (or `partner`/`admin_setup`), device name `Web · <browser>`; the token kept in `localStorage`, sent as `Authorization: Bearer` on API calls only; the browser is listed and revocable on Devices like any device; a 401 forgets the token (with S-18's message for `device_revoked`); Log out forgets the token in the browser, and the device stays valid until revoked from another device (a device cannot revoke itself, AC-3.2; a sign-out endpoint would be a contract change, not taken). The NPM gate also passes, from outside the LAN: a path `/app` or under `/app/`; `/invites/accept`; a request with an `Authorization: Bearer` header whose path does not start with `/health`, `/setup/`, `/i/` or `/apk/` (a helper variable `$kvitt_bearer`, because nginx's `if` takes one condition). Everything else still gets 444; `/i/`, `/health`, `/setup/status` and `/apk/*` stay LAN-or-key only, with or without a bearer header. Lines in `infra/npm/kvitt.conf`, `infra/README.md` step 4 and SPEC §10.5.2 (three identical copies, D-126's rule); step 4a's acceptance test gains: with Wi-Fi off `/app/` shows the login page, `/health` is still a connection error. Checked with nginx 1.31.7 (nginx:alpine) on the Mac: `nginx -t` and 17 requests as intended. Consequence, accepted: the server's authenticated routes are reachable from the internet with a wrong token (401, at most 60 per minute per address by §8.6 and the H-1 fix), and the invite accept by anyone (AC-2.12's lockout, 60-bit codes), which the security review's model already assumed before D-126. | Owner interview 2026-10-21: the web client must work from outside the home; a browser cannot carry the app key, so the gate admits what the web needs and nothing more. The bearer exemption excludes the four public paths so that sending any bearer header does not reopen what D-126 closed. | SPEC §8.5, §10.5, §10.5.2, §15.2, FR-21; `infra/npm/kvitt.conf`; `infra/README.md` steps 4 and 4a |
|
||||
| D-131 | 2026-10-21 | Web stack. `web/` at the repository root: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` generating `web/src/api/schema.d.ts` from `contracts/openapi.yaml` (generated, committed, checked for freshness) with `openapi-fetch` as the client, Recharts, Vitest with Testing Library, ESLint and Prettier, plain CSS modules with tokens from `docs/ux/design-spec.md` §2 (same colour roles, category colours, type scale, the 4/2 radii; light and dark by `prefers-color-scheme`; system font stack, tabular figures; Material Symbols bundled as SVG), JSON dictionaries `sv.json` and `en.json` with `docs/ux/strings.csv`'s keys where a string exists there and `web_` keys otherwise. Money in integer öre with the same rules as `kvitt-domain` and `:domain`: the web's ports of §14.5.2's functions pass their `contracts/test-vectors/*.json` (Vitest), `resolveRange`/`spendSeries` and the §9.11 suggestion pass theirs. Node 25 and npm on the Mac. The web gate, the third gate: `npm run check` in `web/` = `tsc --noEmit`, `eslint .`, `vitest run`, `vite build` and the generated-types freshness check. A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing. Source layout `web/src/{app,api,i18n,design,components,lib,features/<name>}`. | Owner interview 2026-10-21. One typed contract for three clients; the vectors keep the money rules equal on phone, server and web (G-1). | SPEC §11 intro, §14.7, §15.3, FR-21; `CLAUDE.md` "Build and verify" (orchestrator) |
|
||||
| D-132 | 2026-10-21 | Web build and serving. A Node stage in `server/Dockerfile` builds `web/` and the runtime image carries `dist/` at `/app`; `kvitt-server` serves it when `KVITT_WEB_DIR` (default `/app`) exists, with every non-file path under `/app/` answered by `index.html` (SPA fallback), and `/app/*` is 404 otherwise. The build context becomes the repository root (`-f server/Dockerfile`); the workflow (sparse checkout adds `web`, and `contracts` for files the web build reads), its path triggers, `infra/build-images.sh` and `infra/dev/**` follow (W7). The development stack serves it the same way. Hashed assets immutable, `index.html` `no-store`. CSP: NPM sets `Content-Security-Policy` from a variable `$kvitt_csp`, the D-117 policy everywhere and for `/app/` `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, still one server-level `add_header`; the server's own `/app/` header and the exact cache values are the server agent's (see D-135+). No separate publish: a server deploy ships the web client. | Owner interview 2026-10-21: one image, one deploy, nothing new on the Pi; a variable keeps D-117's no-`add_header`-in-locations rule. | SPEC §10.3, §10.5.2, §10.6, §15.4; `infra/npm/kvitt.conf`; `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh` (W7) |
|
||||
| D-132 | 2026-10-21 | Web build and serving. A Node stage in `server/Dockerfile` builds `web/` and the runtime image carries `dist/` at `/app`; `kvitt-server` serves it when `KVITT_WEB_DIR` (default `/app`) exists, with every non-file path under `/app/` answered by `index.html` (SPA fallback), and `/app/*` is 404 otherwise. The build context becomes the repository root (`-f server/Dockerfile`); the workflow (sparse checkout adds `web`, and `contracts` for files the web build reads), its path triggers, `infra/build-images.sh` and `infra/dev/**` follow (W7). The development stack serves it the same way. Hashed assets immutable, `index.html` `no-store`. CSP: NPM sets `Content-Security-Policy` from a variable `$kvitt_csp`, the D-117 policy everywhere and for `/app/` `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, still one server-level `add_header`; the server's own `/app/` header and the exact cache values are the server agent's (as built: D-135 serving, D-137 image build). No separate publish: a server deploy ships the web client. | Owner interview 2026-10-21: one image, one deploy, nothing new on the Pi; a variable keeps D-117's no-`add_header`-in-locations rule. | SPEC §10.3, §10.5.2, §10.6, §15.4; `infra/npm/kvitt.conf`; `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh` (W7) |
|
||||
| D-133 | 2026-10-21 | Advanced analytics in the backend (revises AC-14.1's "the server has no reporting endpoint" and §2's "Server-side analytics or reporting endpoints"). New authenticated read endpoints under `/analytics/*`, designed by the server agent (D-135 to D-138, SPEC v0.29): per-category averages and trends per month, overall and per category, and a forecast of the next 2 calendar months per account. Forecast rule: for each account and each of the next two calendar months, forecast = the sum of the account's recurring templates due in that month (known future expenses) + the average per month of the account's non-recurring spend over the last 3 whole months; the 3-month window is the constant of D-128's trailing moving average (`AnalyticsRules.MOVING_AVERAGE_WINDOW`, 3), shared. The phone keeps computing S-11 and S-12 offline; the advanced analytics come from the server, online only (§9.9), on the web (W-11) and in the app (W6, an Insights section on S-11). | Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." | SPEC §1 G-5, §2, AC-14.1, §15.8 |
|
||||
| D-134 | 2026-10-21 | Plans for the web client programme, disjoint paths so Opus agents run in parallel after the foundation: W0 web foundation (`web/**` except feature folders; scaffold, tokens, shell, router with every route and a placeholder page per feature folder, login, token store, API client and household snapshot, i18n, formatting, shared components, money functions, the gate; running); W1 home and accounts (`features/home`, `accounts`); W2 expenses, search and export (`features/expenses`, `search`, `export`); W3 settle up (`features/settle`); W4 analytics and insights (`features/analytics`; W-11 stubbed until W7); W5 settings (`features/settings`); W6 Android Insights (`android/**`, a UI-contract change, after W7); W7 server web serving and analytics endpoints (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; running). Routes `/app/`, `/app/login`, `/app/accounts/:id`, `/app/expenses/new`, `/app/expenses/:id`, `/app/settle`, `/app/settlements/:id`, `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights`, `/app/search`, `/app/settings/*`. One `PLAN-W<n>.md` and `prompts/W<n>.md` each. | Owner interview 2026-10-21: run every independent task in parallel. | SPEC §11.1, §11.2, §11.3, §14.7, §15.9; `PLAN-W0.md` to `PLAN-W7.md`; `prompts/W0.md` to `prompts/W7.md` |
|
||||
| D-135 | 2026-10-21 | Web serving in `kvitt-server` (refines D-132). `KVITT_WEB_DIR` (default `/app`, `DEFAULT_WEB_DIR` in `config.rs`). `GET /app` → 308 to `/app/`; `/app/` and `/app/index.html` serve `index.html`; a safe path naming a regular file serves it; any other path under `/app/` serves `index.html` (SPA fallback), except a missing file under `assets/`, which is 404 `not_found`. `Cache-Control`: `assets/*` `public, max-age=31536000, immutable`; `index.html` `no-store`; other files `no-cache`. Every `/app/` response carries the CSP of NPM's `$kvitt_csp` web policy (`APP_CSP`, identical text) plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`; `Content-Type` from the extension. Path safety: at most 512 bytes and 8 segments, each 1 to 255 characters of `[A-Za-z0-9._-]` not starting with `.`; an unsafe path is never joined to the directory (no `..`, hidden file, absolute path, back-slash or percent trick reaches the disk). Without the directory every `/app` path is 404 `not_found`, as before the web client. A small handler (`src/api/web.rs`), no tower-http; not an API route (no protocol check, no token, not in `contracts/openapi.yaml`). Tests `tests/core_api/web.rs`. Landed `50a0d67`. | A missing hashed asset must fail rather than load HTML as JavaScript; `index.html` names the current assets, so it is never cached, while hashed names make a year's cache safe; the server sends NPM's policy itself so the development stack behaves as the Pi; a strict character allowlist is simpler to prove safe than path normalisation. | SPEC §7, §10.4, §15.2, §15.4, AC-21.1; `server/crates/kvitt-server/src/api/web.rs`, `config.rs`, `router.rs` |
|
||||
| D-136 | 2026-10-21 | `/app`, `/app/` and `/app/*` count against the unauthenticated limit of 60 requests per minute per client IP (§8.6), even when the request carries a bearer token (`PUBLIC_ROUTES` in `rate_limit.rs`). Landed `50a0d67`. | The files are public and need no token; a page load is `index.html` plus a few hashed assets the browser then caches for a year, so 60 per minute leaves room for reloads, and a bearer header must not move static files into the 600-per-device bucket. | SPEC §8.6; `server/crates/kvitt-server/src/api/rate_limit.rs` |
|
||||
| D-137 | 2026-10-21 | Server image build with the web client (refines D-132). Build context: the repository root with an allowlist `.dockerignore` at the root (`*`, then `!server`, `!web`, `!contracts`; excluded inside them `server/target`, `server/data`, `web/node_modules`, `web/dist`, `**/*.sqlite*`, `**/*.db`, `**/*.apk`, `**/*.pem`, `**/.env`, `**/.env.*`, `**/.DS_Store`); `server/.dockerignore` removed. `server/Dockerfile` stage `web`: `node:24.22.1-bookworm-slim` pinned by the digest of its multi-arch index (Node 24, Active LTS on 2026-10-21), `--platform=$BUILDPLATFORM`, `npm ci` (npm cache mount), `npm run build` in `/repo/web` beside `/repo/contracts`, `test -f dist/index.html`; a `web/` without `package.json`/`package-lock.json` fails the build by design. Its `dist` is copied into the Rust stage (`/out/web`) and from there to `/app` in the runtime image, so npm and rustc never run together on the 1 GB Pi 3 runner; `CARGO_BUILD_JOBS=1` kept. Workflow: context `.` with `file: server/Dockerfile`, sparse checkout adds `web` and `contracts`, the path filter adds `web/**` and `contracts/**`; `infra/build-images.sh` builds `kvitt-server` with context `.`; `infra/dev/compose.yaml` has no build section, so `infra/dev/build.sh` changed (default context the repository root, `KVITT_SERVER_BUILD_CONTEXT` still overrides). Also: `cargo audit` needed `rustls-webpki` 0.103.16 and `webpki-root-certs` 1.0.10 in `Cargo.lock` (RUSTSEC-2026-0382, -0383, published 19–20 October 2026). The Node stage's duration on the Pi 3 is not yet measured; until `web/package.json` lands (W0) every server image build fails at the Node stage. Landed `50a0d67`. | An allowlist keeps the Android project, goldens, worktrees and secrets out of a root context; building the web on the build platform avoids emulating npm; chaining the stages keeps BuildKit from running npm and rustc in parallel on 1 GB. | SPEC §10.3, §15.4; `.dockerignore`, `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh`, `infra/dev/build.sh`, `infra/README.md`, `server/Cargo.lock` |
|
||||
| D-138 | 2026-10-21 | Server analytics endpoints and rules (fills in D-133; new SPEC §14.5.7). E-34 `GET /analytics/trends?months=…&measure=…` (`months` 1 to 24, default 6; `measure` `household` or `my_share`, default `household`) → `{measure, today, current_month, months, household: Trend, categories: [{category_id, trend}]}`, `Trend = {total_ore, average_ore, last_month_ore, last_month_vs_average_pct?, current_month_ore, points: [{month, amount_ore, moving_average_ore}]}` (the brief's two endpoints, averages and trends, merged into one). E-35 `GET /analytics/forecast?measure=…` → `{measure, today, current_month, accounts: [{account_id, current_month_ore, months: [{month, recurring_ore, baseline_ore, forecast_ore}] ×2}]}`, `months` ignored. Both authenticated, read-only; invalid parameters 400 `invalid_request` with `field`; unknown keys ignored. Rules (pure functions in `kvitt-domain::analytics`: `div_half_up`, `average`, `moving_average`, `percent_vs_average`, `occurs_in_period`, `MOVING_AVERAGE_WINDOW` = 3): months are calendar months of `expense_date`; today is the Europe/Stockholm date; the whole months are the N before the current month; averages divide by N with empty months counting, rounding half away from zero; percent against the rounded average, half away from zero, null when that average is 0; the moving average trails over 3 months (D-128's window), the first points averaging what exists; "current month so far" runs from the 1st through today; categories listed only when non-zero, ordered by total descending, then id; the forecast counts non-paused, non-deleted templates once per month within their start and end periods and after `last_generated_period` (templates are monthly only, `day_of_month`), `my_share` with the shares a generation would write today; baseline = non-generated spend over the last 3 whole months ÷ 3 (a new account reads low); accounts are the non-deleted ones, archived included; settlements never count, deleted expenses never, archived accounts and archived or deleted categories do. Examples `contracts/examples/E-34-analytics-trends/response.json`, `E-35-analytics-forecast/response.json`; `contracts/openapi.yaml` operations `getAnalyticsTrends`, `getAnalyticsForecast`. Server tests 299; `tools/contracts-check.py` 136 checks (operations E-1 to E-35). A contract change (§14.8): the Android `:data` example tests need DTOs for the two examples (W6). Landed `50a0d67`. | One endpoint per screen section keeps W-11 and the app's Insights to two calls; computing in SQL per month and rounding in `kvitt-domain` keeps the rules beside the other money rules and equal to S-12's (D-128). | SPEC §7, §14.3.4, §14.3.5, §14.5.7, §15.8, AC-14.1; `server/crates/kvitt-domain/src/analytics.rs`, `server/crates/kvitt-server/src/api/analytics.rs`, `wire/analytics.rs`, `contracts/openapi.yaml`, `contracts/examples/E-34-*`, `E-35-*`, `tools/contracts-check.py` |
|
||||
| D-139 | 2026-10-21 | Unattended APK deploy. `infra/publish-apk.sh --deploy` copies `kvitt-<code>.apk` first and `latest.json` last into the server's `data/apk/` on the Kvitt Pi with a dedicated forced-command key `infra/secrets/pi_deploy_ed25519` (git-ignored; its public half in the owner's `~/.ssh/authorized_keys` on the Pi prefixed `command="scp -t /home/jacobhagstedt/kvitt/data/apk",restrict`, so it can only write files into `data/apk/`), with legacy scp (`-O`), the Pi's host key pinned on first use in `infra/secrets/pi_known_hosts`; then requests `https://<KVITT_PUBLIC_HOST>/apk/latest` with `X-Kvitt-App` (the key from `android/local.properties`, fed to curl on stdin, never printed) and fails unless it returns the new `version_code`. Without the key file or `kvitt.appKey`, `--deploy` refuses to start; without `--deploy` nothing changes, and the manual scp stays the fallback. First deploy 2026-10-21: `version_code` 133 served. Landed `70cb25c`. | Publishing an APK was a manual two-file copy the owner had to do; a forced-command key keeps the Mac's access to the Pi to one directory (no shell), and the `/apk/latest` check proves the phones will see the update. | SPEC §10.6; `infra/publish-apk.sh`, `infra/README.md` "Publishing an APK" |
|
||||
|
||||
Reference in New Issue
Block a user