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:
2026-10-21 21:00:45 +02:00
parent c0f4345ee1
commit 3fbd393082
4 changed files with 75 additions and 29 deletions
+49 -14
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.28** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.29** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
@@ -257,7 +257,7 @@ The server starts with an empty database. The household is created from the comm
### FR-14 Analytics
* AC-14.1 Analytics (S-11, S-12) are computed on the phone from Room with the pure function of §14.5.4. They work offline and include pending local changes. Revised by D-133: the advanced analytics (per-category averages and trends per month, the two-month forecast per account) come from the server's `/analytics/*` endpoints, online only (§9.9), shown on the web (W-11) and in the app's Insights section (W6) (§15.8); the web client computes S-11 and S-12's equivalents in the browser with the same functions (§15.5).
* AC-14.1 Analytics (S-11, S-12) are computed on the phone from Room with the pure function of §14.5.4. They work offline and include pending local changes. Revised by D-133: the advanced analytics (per-category averages and trends per month, the two-month forecast per account) come from the server's `/analytics/*` endpoints (E-34 trends, E-35 forecast, rules §14.5.7, D-138), online only (§9.9), shown on the web (W-11) and in the app's Insights section (W6) (§15.8); the web client computes S-11 and S-12's equivalents in the browser with the same functions (§15.5).
* AC-14.2 Range presets: `this_month`, `last_3_months`, `last_6_months`, `last_12_months`, `this_year`, `all_time`, `custom` (start and end date pickers). "Last N months" means the current month and the N-1 months before it, whole months. Default `last_6_months`.
* AC-14.3 Granularity: `month` or `week` (ISO, Monday start). Default `month`.
* AC-14.4 Measure: `household` (sum of full expense amounts, whoever paid) or `my_share` (sum of the viewer's shares). Default `household`.
@@ -316,7 +316,7 @@ The server starts with an empty database. The household is created from the comm
The web client of §15 (D-129 to D-134). Its acceptance criteria:
* AC-21.1 `https://<KVITT_PUBLIC_HOST>/app/` serves the web client from the server image; every path under `/app/` that is not a file serves `index.html`; without the web files (`KVITT_WEB_DIR` missing) `/app/*` is 404 `not_found` (§15.4).
* AC-21.1 `https://<KVITT_PUBLIC_HOST>/app/` serves the web client from the server image; every path under `/app/` that is not a file serves `index.html`, except a missing `assets/*` file, which is 404 `not_found` (D-135); without the web files (`KVITT_WEB_DIR` missing) `/app/*` is 404 `not_found` (§15.4).
* AC-21.2 A browser logs in with an invite code through E-3 like a phone pairs (any of the three kinds), as device `Web · <browser>`; the token is kept in `localStorage` and sent as a bearer token; the browser appears on Devices and can be revoked from another device (§15.2).
* AC-21.3 A 401 forgets the token and shows the login page; `device_revoked` adds the S-18 message. Log out forgets the token and says that the browser stays on Devices until revoked.
* AC-21.4 Every page of §15.6 shows the server's current data: the household snapshot is pulled from `GET /sync` on load, on window focus and after every write; nothing but the token, the language choice and per-viewer conveniences survives a reload (§15.5).
@@ -560,8 +560,10 @@ Base URL: `https://<KVITT_PUBLIC_HOST>` (the value of `KVITT_PUBLIC_URL`, port 4
| E-31 | GET | `/sync` | device | Pull changes since a cursor |
| E-32 | POST | `/sync` | device | Push local expense and settlement changes |
| E-33 | POST | `/settlements/proposal` | device | Server-side allocation proposal |
| E-34 | GET | `/analytics/trends` | device | Spending per month, averages and trends, household and per category (D-133, D-138) |
| E-35 | GET | `/analytics/forecast` | device | Forecast of the next two calendar months per account (D-133, D-138) |
Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately.
Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`; E-34 and E-35 are computed read-only views over them (§14.5.7), not a second way to read rows. The web client's static files under `/app/` (§15.4, D-135) are not API endpoints: they have no E-number, no protocol check and no entry in `contracts/openapi.yaml`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately.
Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3.1 file `contracts/openapi.yaml` is written by milestone M1 from §14.3, which is binding.
@@ -612,7 +614,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM
### 8.6 Rate limiting and input limits
* Unauthenticated endpoints other than E-6 (and unknown paths): at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds.
* Unauthenticated endpoints other than E-6, the web client's files (`/app`, `/app/` and `/app/*`, D-136) and unknown paths: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. The `/app` paths count against the client IP even when the request carries a bearer token (D-136): a page load is `index.html` and a few hashed assets that the browser then caches for a year (§15.4), so 60 per minute leaves room for reloads.
* APK downloads (E-6, `GET /apk/files/{file_name}`): at most 10 requests per minute per client IP, then 429 `rate_limited` with `Retry-After`. Own fixed window, not counted against the 60; every request counts, a 404 included (D-119, security review L-3).
* Authenticated endpoints: at most 600 requests per minute per device.
* Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without `X-Kvitt-Protocol` became accepted for good, bypassed the IP limit and was never pruned). All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::<SocketAddr>()` (added by M4 in `15056dc`, D-032 fulfilled).
@@ -928,10 +930,10 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it
### 10.3 Images, registry and build
* Image names (D-104, D-120): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`).
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server` and `infra/backup`, `sparse-checkout-cone-mode: true`), the two build contexts (neither Dockerfile copies anything outside its context), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `b4d9467`). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server`, `web`, `contracts` (both added by D-137) and `infra/backup`, `sparse-checkout-cone-mode: true`), what the two builds read (the server image builds from the repository root with the allowlist `.dockerignore` since D-137; the backup image from `infra/backup`), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `web/**`, `contracts/**` (the last two added by D-137), `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `b4d9467`). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the two images for `linux/arm64`, tag defaults to the short git SHA of `HEAD`, also tags `latest`; pushes when `infra/secrets/registry.env` (git-ignored: `KVITT_REGISTRY`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the `tools/gradle-serial.sh` lock (the lock check shared through `tools/gradle-lock.sh`, `2fac1ec`). Works with Docker buildx and with Podman.
* Web client (D-132): `server/Dockerfile` gains a Node stage that builds `web/` (`npm ci`, `vite build`) and the runtime image carries the result at `/app` (`KVITT_WEB_DIR`, §15.4). The build context becomes the repository root (`-f server/Dockerfile` with context `.`), so the workflow, its sparse checkout (adding `web`, and `contracts` for the files the web build reads, §15.7) and its path triggers (adding `web/**`), `infra/build-images.sh` and `infra/dev/**` change with it (W7; the exact Node image and pin are the server agent's, D-135+). The Pi runner builds the Node stage too; the web client adds no image and no service.
* `server` image: multi-stage. Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
* Web client (D-132, as built D-137, `50a0d67`): the server image's build context is the repository root (`docker build -f server/Dockerfile .`) with an allowlist `.dockerignore` at the root: everything is excluded except `server`, `web` and `contracts`, and inside them `server/target`, `server/data`, `web/node_modules`, `web/dist`, SQLite and `.db` files, APKs, `.pem` files, `.env` files and `.DS_Store` are excluded too, so the Android project, the docs, the goldens, worktrees and secrets never enter the context; `server/.dockerignore` is removed (`infra/backup/Dockerfile` builds from its own directory and does not read the root file). `server/Dockerfile` has three stages: (1) `web`: `docker.io/library/node:24.22.1-bookworm-slim` pinned by the digest of its multi-arch index (Node 24, the Active LTS line on 2026-10-21), on `$BUILDPLATFORM` (the output is static files, so an amd64 build never emulates npm), `npm ci` with a BuildKit npm cache, then `npm run build` in `/repo/web` beside `/repo/contracts` (the web build may read `../contracts`), failing unless `dist/index.html` exists; a `web/` without `package.json` and `package-lock.json` fails the build by design; (2) the Rust build stage, which copies the web stage's `dist` to `/out/web`, so it orders the two builds and npm and rustc never run at once on the 1 GB Pi 3 runner (BuildKit would otherwise run the independent stages in parallel; a web-only change re-runs the cargo step, which finds nothing to compile); `CARGO_BUILD_JOBS=1` stays; (3) the runtime stage, which copies `/out/web` to `/app` (owned by root, read-only for uid 10001; `KVITT_WEB_DIR` §10.4, serving §15.4). The workflow builds with context `.` and `file: server/Dockerfile`, its sparse checkout and path triggers add `web` and `contracts` (above); `infra/build-images.sh` builds `kvitt-server` with context `.`; `infra/dev/compose.yaml` has no build section, so `infra/dev/build.sh` builds with the repository root as context (`KVITT_SERVER_BUILD_CONTEXT` still overrides it). The Pi runner builds the Node stage too (its duration on the Pi 3 not yet measured); the web client adds no image and no service.
* `server` image: multi-stage (since D-137 the Node `web` stage above runs first; the stages below are the Rust build and the runtime). Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `tzdata`, and `infra/backup/backup.sh` run by supercronic; the healthcheck is `infra/backup/healthcheck.sh`.
* The backup sidecar schedules with supercronic instead of busybox `crond` (D-104): it runs as a non-root user, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (`30 3 * * *`).
* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for the backup sidecar; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. The Caddy pin and the cert-sync packages left with D-120; Nginx Proxy Manager is the owner's existing install, and the block of §10.5.2 was written against NPM v2.16.0's templates. Docker Engine on the Kvitt Pi (a prerequisite, already installed, D-125) comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`.
@@ -950,6 +952,7 @@ Environment variables (exhaustive):
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project |
| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name whose resolved address may set `X-Forwarded-For` (not a CIDR). The default `none` (`NO_TRUSTED_PROXY` in `config.rs`, compared ignoring case and surrounding white space) means no proxy and is never looked up (D-123; was `caddy`); an empty value counts as unset. Compose sets it to `HA_HOST`, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy, D-022 unchanged); with `none` or an unresolvable name no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) |
| `KVITT_WEB_DIR` | `/app` | Directory of the built web client, served under `/app/` (D-135, §15.4). Missing (an image without the web client, `cargo run` in development): every `/app` path is 404 `not_found` |
| `RUST_LOG` | `info` | tracing filter |
The time zone `Europe/Stockholm` is a constant in code, not configuration.
@@ -1118,6 +1121,7 @@ location ^~ /i/ {
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, no port, so it matches every port of the host, path prefix `/i/`, `android:autoVerify="false"`, D-124) and as `RELEASE_SERVER_URL` = `releaseServerUrl(host)` = `https://<host>` (`link/ReleaseServer.kt`, `releaseServerUrl` in `:feature:onboarding`'s `ServerAddress.kt`; `RELEASE_SERVER_PORT` removed, D-124); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (`:443`, `:80`) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`. Its sibling `kvitt.appKey` (D-126, next item) is read the same way.
* The app key is a build property too (D-126, modelled on D-121): `kvitt.appKey` from the Gradle property (`-Pkvitt.appKey=…`), else the key `kvitt.appKey` in `android/local.properties`, else empty (no `X-Kvitt-App` header sent); `BuildConfig.APP_KEY` (§8.5). A release APK carries the key it was built with, so the APK must be rebuilt after the key is set or rotated; `infra/publish-apk.sh` needs no change, because it builds on the Mac, where `android/local.properties` holds the key. An APK without the key cannot reach the server from outside the home LAN once the app gate is in NPM, its update download included, so both phones run a keyed APK before the gate goes in (`infra/README.md` step 4a). Anyone holding the APK holds the key; the owner alone distributes it (first install on both phones by hand) and rotates the key if an APK leaks (§8.5).
* With `--deploy` (D-139) the script copies the APK first and `latest.json` last itself, with the forced-command deploy key `infra/secrets/pi_deploy_ed25519` (scp into `data/apk/` only), and fails unless `https://<KVITT_PUBLIC_HOST>/apk/latest`, requested with `X-Kvitt-App`, returns the new `version_code`; the manual scp stays the fallback (`infra/README.md` "Publishing an APK").
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`). Since the app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN or a leaked key.
* Older APKs stay in `data/apk/` on the Pi until the owner removes them by hand (D-125; `install-apk.sh` kept the newest 5, D-103); `infra/pi/out/apk/` on the Mac keeps the newest 5.
@@ -1344,7 +1348,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| W4 | Web: analytics and insights | Planned. W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (stubbed until W7 lands); `resolveRange` and `spendSeries` passing their vectors; in `web/src/features/analytics/` | Web gate | Look at the analytics pages in a browser (yes/no) | W0; W-11 needs W7 |
| W5 | Web: settings | Planned. W-13 to W-19 in `web/src/features/settings/` | Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no) | W0 |
| W6 | Android: Insights | Planned. An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no) | W7 landed, its image deployed |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Running (2026-10-21). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10) | SPEC v0.28 |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10) | SPEC v0.28 |
### 11.3 Notes per milestone
@@ -2389,9 +2393,26 @@ The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "http
```
Computed with `kvitt-domain::propose_allocation` over the server's non-deleted rows. Errors: `invalid_request`, `validation_failed`, `no_accounts`.
**E-34 `GET /analytics/trends?months=<int>&measure=<household|my_share>`** (device, read-only, D-133, D-138; operation `getAnalyticsTrends`). `months` 1 to 24, default 6; `measure` default `household` (AC-14.4). The two endpoints the D-134 brief named (averages, trends) are one. 200:
```json
{ "measure": "household", "today": "2026-10-17", "current_month": "2026-10", "months": 3,
"household": Trend,
"categories": [ { "category_id": "…", "trend": Trend } ] }
```
`Trend` = `{ "total_ore": int, "average_ore": int, "last_month_ore": int, "last_month_vs_average_pct": int?, "current_month_ore": int, "points": [ { "month": "YYYY-MM", "amount_ore": int, "moving_average_ore": int } ] }`: `points` are the `months` whole months before the current one, oldest first; `last_month_vs_average_pct` is null when the average is 0. `today` is the Europe/Stockholm date, `current_month` its `YYYY-MM`. Rules §14.5.7. Errors: `invalid_request` with `field` `months` or `measure`. Unknown query keys are ignored; the last occurrence of a key wins.
**E-35 `GET /analytics/forecast?measure=<household|my_share>`** (device, read-only, D-133, D-138; operation `getAnalyticsForecast`). `months` is not a parameter and is ignored like any unknown key. 200:
```json
{ "measure": "household", "today": "2026-10-17", "current_month": "2026-10",
"accounts": [ { "account_id": "…", "current_month_ore": 12000,
"months": [ { "month": "2026-11", "recurring_ore": 4900, "baseline_ore": 40000, "forecast_ore": 44900 },
{ "month": "2026-12", "recurring_ore": 0, "baseline_ore": 40000, "forecast_ore": 40000 } ] } ] }
```
One entry per non-deleted account (archived included), ordered by `created_at`, then id; `months` are always the two calendar months after the current one; `forecast_ore = recurring_ore + baseline_ore`. Rules §14.5.7. Errors: `invalid_request` with `field` `measure`.
#### 14.3.5 Examples as contract
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have `server_version` 15 and 16; `E-7-me/response.json` shows those values and its test compares them (D-058).
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have `server_version` 15 and 16; `E-7-me/response.json` shows those values and its test compares them (D-058). E-34 and E-35 have response examples only (`E-34-analytics-trends/response.json`, `E-35-analytics-forecast/response.json`, v0.29, D-138); `tools/contracts-check.py` checks that the operations are exactly E-1 to E-35 (136 checks at `50a0d67`).
### 14.4 Sync protocol v1
@@ -2759,6 +2780,19 @@ fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>,
* `parseCategoryKeywords` (D-056) reads the text of `contracts/category-keywords.csv`: header `keyword,match,default_key,source`, entries in file order (table order), `match` mapped to `KeywordMatch`; a malformed table (wrong header, wrong column count, unknown `match`) throws `IllegalArgumentException`. Pure; shared by `:data` and `:core:fakes`, which read the bundled resource (§14.2.4, D-055).
* `fallback` is the step-4 category id (AC-8.6), computed by the caller; it is returned as given when no earlier step applies, also when the normalised description is empty.
#### 14.5.7 Server analytics (Rust only, D-138)
The rules of E-34 and E-35 (D-133). Pure functions in `kvitt-domain` (module `analytics`, no clock, no time zone): `div_half_up`, `average`, `moving_average`, `percent_vs_average`, `occurs_in_period`, and the constant `MOVING_AVERAGE_WINDOW` = 3 (D-128's window, shared with the phone's `AnalyticsRules.MOVING_AVERAGE_WINDOW`; a change changes both). `kvitt-server` (`src/api/analytics.rs`) picks the months and sums in SQL per group and month; no vector file covers these rules (unit and integration tests do).
* **Months and today.** A month is the calendar month of the stored `expense_date` (`YYYY-MM-DD`, no time zone). "Today" is the Europe/Stockholm date. The whole months are the N months before the current month (E-34: N = `months`; E-35's baseline: N = 3). "Current month so far" runs from the 1st of the current month through today; expenses dated after today never count.
* **What counts.** Non-deleted expenses only; settlements never (AC-14.6); expenses in archived accounts and in archived or deleted categories do (AC-14.9). `household` adds full amounts, `my_share` the requesting user's shares (AC-14.4).
* **Averages.** Total of the N whole months ÷ N, empty months counting as 0, rounded half away from zero to öre (`div_half_up`: 3/2 = 2, −3/2 = −2; 0 for N = 0).
* **Percent.** `last_month_vs_average_pct` compares the last whole month with the rounded average: (last − average) × 100 ÷ average in whole percent, half away from zero, negative below the average; null when the rounded average is 0.
* **Moving average.** Trailing over 3 months: each point with the two before it; the first points average what exists (1, then 2 points), half away from zero.
* **Categories (E-34).** A category is listed only when it has a non-zero amount in a whole month or in the current month; ordered by `total_ore` descending, then category id. `household` is the sum over all categories.
* **Recurring (E-35).** Per account and forecast month, `recurring_ore` is the sum over the account's non-paused, non-deleted templates that occur in that month, once per template and month (templates are monthly only, on `day_of_month`, §9.5): `start_period` ≤ month, month ≤ `end_period` when set, and month > `last_generated_period` when set (`occurs_in_period`). For `my_share` it is the share a generation would write today (mode `default`: the account's current default ratio).
* **Baseline (E-35).** The account's spend over the last 3 whole months, expenses generated from a template excluded, ÷ 3, half away from zero (D-133); months without spend count, so a new account reads low. `current_month_ore` is the current month so far, generated expenses included.
### 14.6 Push payload v1
FCM HTTP v1 message the server sends, one per target device (`POST https://fcm.googleapis.com/v1/projects/kvitt-1337/messages:send`):
@@ -2948,7 +2982,7 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w
* **Headers** (§14.3.1): `X-Kvitt-Protocol: 1`; `X-Kvitt-App-Version` with the web build's version code and `X-Kvitt-App-Version-Name` with its version name (both from the build, W0; the name is at most 32 characters, so it shows on S-20 like a phone's); `Accept-Language` is the browser's own. No `X-Kvitt-App`: a browser cannot carry the app key (§8.5).
* **The NPM gate** (§8.5, §10.5.2, extends D-126). From outside the home LAN a request passes when it carries `X-Kvitt-App` with the key, comes from the LAN, is Let's Encrypt's challenge path, **or** its path is `/app` or starts with `/app/`, **or** its path is `/invites/accept`, **or** it carries an `Authorization: Bearer` header and its path does not start with `/health`, `/setup/`, `/i/` or `/apk/`. Everything else still gets 444. So the invite landing page (`/i/`), `/health`, `/setup/status` and the APK downloads stay LAN-or-key only, with or without a bearer header; the web client sends its bearer on API calls only.
* **Consequence (accepted).** The server's authenticated routes become reachable from the internet by anyone who sends some bearer token: a wrong token gets 401 `unauthorized` and counts against its address's unauthenticated limit of §8.6 (the H-1 fix, D-115, keeps junk tokens from ever counting as accepted), so the bound is 60 such requests per minute per address. `POST /invites/accept` is reachable from the internet and bounded by the 10-failures-per-hour lockout of AC-2.12 and the code's 60 bits (§8.3). The web client's static files are public (they hold no data and no secret). The security review's threat model (§8.1) assumed a reachable API before D-126; the gate stays a perimeter against scanners, and the device token stays the authentication.
* **Browser hardening.** The `/app/` pages get a Content-Security-Policy that allows only the server's own origin for scripts, styles, images (plus `blob:` and `data:`), fonts and API calls, with `frame-ancestors 'none'` (§10.5.2; the server's own `/app/` header is W7's, see the server agent's D-135+, and is hidden behind NPM like the D-117 headers). No third-party script, font or CDN at run time: everything the page loads is in the image. `localStorage` is readable by any script on the origin, so the policy's `script-src 'self'` is what protects the token.
* **Browser hardening.** The `/app/` pages get a Content-Security-Policy that allows only the server's own origin for scripts, styles, images (plus `blob:` and `data:`), fonts and API calls, with `frame-ancestors 'none'` (§10.5.2; the server sends the same policy itself on every `/app/` response, with `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and `Referrer-Policy: no-referrer`, D-135, so the development stack behaves as the Pi; behind NPM its `$kvitt_csp` header applies like the D-117 headers). No third-party script, font or CDN at run time: everything the page loads is in the image. `localStorage` is readable by any script on the origin, so the policy's `script-src 'self'` is what protects the token.
### 15.3 Stack and layout (D-131)
@@ -2962,8 +2996,8 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w
### 15.4 Build and serving (D-132)
* `server/Dockerfile` gets a Node stage that builds `web/` (`npm ci`, `vite build`); the runtime image carries `dist/` at `/app`. The build context becomes the repository root (`docker build -f server/Dockerfile .`), so the Gitea workflow, `infra/build-images.sh` and `infra/dev/**` change with it and the workflow's sparse checkout adds `web` (and `contracts` if the web build reads from it; W2's keyword table does, §15.7) (W7). The web client is part of the server image and its version: there is no separate publish, no separate container and no change to `infra/pi/compose.yaml`; a deploy (§10.10) ships both.
* `kvitt-server` serves the files when the directory `KVITT_WEB_DIR` (default `/app`) exists: `GET /app/<file>` for files in it, `GET /app/` and every other path under `/app/` that is not a file get `index.html` (the client-side router handles the path), `/app` without the slash leads to `/app/`. Without the directory, `/app/*` is 404 `not_found`. Hashed asset files are cached as immutable, `index.html` is sent with `Cache-Control: no-store`. The exact handling and header values, whether these routes appear in `contracts/openapi.yaml` (E-4's HTML page does) and the `KVITT_WEB_DIR` row of §10.4 are W7's (see the server agent's D-135+).
* `server/Dockerfile` gets a Node stage that builds `web/` (`npm ci`, `vite build`); the runtime image carries `dist/` at `/app`. The build context becomes the repository root (`docker build -f server/Dockerfile .`), so the Gitea workflow, `infra/build-images.sh` and `infra/dev/**` change with it and the workflow's sparse checkout adds `web` (and `contracts` if the web build reads from it; W2's keyword table does, §15.7) (W7; as built §10.3, D-137). The web client is part of the server image and its version: there is no separate publish, no separate container and no change to `infra/pi/compose.yaml`; a deploy (§10.10) ships both.
* `kvitt-server` serves the files when the directory `KVITT_WEB_DIR` (default `/app`) exists: `GET /app/<file>` for files in it, `GET /app/` and every other path under `/app/` that is not a file get `index.html` (the client-side router handles the path), `/app` without the slash leads to `/app/`. Without the directory, `/app/*` is 404 `not_found`. As built (D-135, `50a0d67`, `src/api/web.rs`, a small handler without tower-http): `GET /app` → 308 to `/app/`; `/app/` and `/app/index.html` serve `index.html`; a safe path naming a file serves it; any other path falls back to `index.html`, except a missing file under `assets/`, which is 404 `not_found` (a vanished hashed asset must fail, not load HTML). `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 (§10.5.2) plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`. 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 and falls back like a missing one. The routes are not in `contracts/openapi.yaml` (static files, not API). Rate limit §8.6 (D-136); `KVITT_WEB_DIR` §10.4.
* The development stack (`infra/dev`) serves the web client the same way, from its image. For work on the web client, `npm run dev` in `web/` runs Vite's dev server with `/app/` as its base and forwards API paths to a Kvitt server (W0 documents the setting); agents use a throwaway server (`cargo run -p kvitt-server -- serve` with a temporary `KVITT_DATA_DIR`), never the owner's development or Pi data.
### 15.5 Data: reads, writes, freshness
@@ -3010,7 +3044,7 @@ Navigation (W0): a sidebar from 720 px wide and a bottom bar under it, with Home
### 15.8 Advanced analytics on the server (D-133)
* Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." New read endpoints under `/analytics/*` (authenticated, any device), designed by W7: per-category averages and trends per month, overall and per category; and a forecast of the next 2 calendar months per account. Names, shapes, the exact averaging and rounding are the server agent's (D-135 to D-138, SPEC v0.29), recorded with its report.
* Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." New read endpoints under `/analytics/*` (authenticated, any device), designed by W7: per-category averages and trends per month, overall and per category; and a forecast of the next 2 calendar months per account. As built (v0.29, D-138): E-34 `GET /analytics/trends` (averages and trends, household and per category; the two endpoints merged into one) and E-35 `GET /analytics/forecast` (§7, §14.3.4); averaging, rounding and month rules §14.5.7.
* **Forecast rule** (settled): for each account and each of the next two calendar months, forecast = the sum of the account's recurring templates due in that month (the known future expenses: active, not paused, inside their start and end periods, §9.5) + the average per month of the account's non-recurring spend (expenses not generated from a template) 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, counted in buckets there and in whole months here), shared, not a second number; the server holds its own copy of the value and a change to it changes both.
* AC-14.1 is revised: the phone keeps computing S-11 and S-12 offline from Room; 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).
@@ -3064,3 +3098,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
| 0.26 | 2026-10-21 | Pull-to-refresh on Home and account detail (D-127, owner request 2026-10-21; Android `3778f5f`; revises design-spec S-4 "Pull-to-refresh is not offered"): pulling S-4 or S-5 past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu"; the indicator stays while the sync runs and goes when it returns, success or failure; a failure shows only in the sync pill (S-5 has none, so a failed pull there shows nothing until Home's pill); offline the pull ends at once without a sync; a pull while a sync runs starts no second one. Shared `KvittPullToRefresh` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator (48 dp `primary-container` disc, the `sync` icon 18 dp turning 270° with the pull and popping from 80 % at the threshold on the new `KvittMotion.spatialFast()` with a `GestureThresholdActivate` haptic, a 32 dp `primary` arc that fills to the threshold and turns indeterminate while refreshing); `ContainedLoadingIndicator` replaces the arc once the pinned Material 3 (1.4.0) ships it. TalkBack reads "Synkar…" (`sync_syncing`, reused) as a polite live region; S-16 stays the TalkBack path. New goldens `S-4-home-refreshing` and `S-5-account-refreshing`; 959 Android tests. No change to `:core:model`, Room, the sync protocol, the server or `docs/ux/strings.csv` (AC-15.3, §9.9, §13.3; design-spec §2.11, §3.1, §3.6, S-4, S-5). |
| 0.27 | 2026-10-21 | S-12 line chart for a category or account over the range (D-128, owner request 2026-10-21; Android `12e2508`). S-12 opened from a breakdown row shows its own period chip (AC-14.2 presets plus custom) and Month/Week toggle, both starting from S-11's values and changing chart, total and list on S-12 only (S-11 keeps its state; measure and group-by come from S-11 and are fixed on S-12), the header total, the "Utgifter över tid" card and the expense rows; a drill-down from a bar is unchanged. One point per bucket, empty buckets as zero (AC-14.7), from the group's bucket totals of `spendSeries` (§14.5.4); an empty range shows only the 0 baseline (D-068) and "Inga utgifter i den här perioden". Overlays: the range average (total ÷ buckets, half-up to öre) as a dashed line in the group colour with its amount at the right end; a trailing moving average over 3 buckets (`AnalyticsRules.MOVING_AVERAGE_WINDOW`; the first buckets use the 1 or 2 that exist) as a thinner neutral line; legend "Snitt", "Glidande snitt (3)"; caption "<Bucket>: N % över snittet" / "under snittet" / "på snittet" for the latest bucket the range does not cut short, N a whole percent half-up. Overlays and caption omitted when the average is 0 or the range has one bucket; caption omitted when no bucket is whole. Not interactive; one TalkBack summary. `:core:design` gains `SpendLineChart` and `LineLegend` (`chart/LineChart.kt`), axis code shared with `StackedBarChart` in `Charts.kt`; `AnalyticsFilterBar` measure and group-by toggles optional. Seven `strings.csv` keys (569 rows). New goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants`; `s12_category`, `s12_account`, `s12_long` re-recorded; 977 Android tests. No change to `:core:model`, `:domain`, `:core:fakes`, Room, the server or the contracts (new AC-14.11; §13.3 S-12, §13.4; design-spec §3.10, S-12). |
| 0.28 | 2026-10-21 | The web client programme (owner interview 2026-10-21; no implementation yet; v0.27 is D-128's). D-129: a web client with the Android app's features except the phone-only ones (pairing flow, push, offline, in-app update), plus free-text search, CSV export of the filtered list, keyboard shortcuts N, /, Esc, ? and a language choice; desktop first, one column under 720 px, sv and en, not a PWA (§1, §2 revised, new §15, FR-21). D-130: the browser logs in with an invite code through E-3 as device `Web · <browser>`, token in `localStorage`, revocable on Devices; the NPM gate also passes `/app` and `/app/…`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/` and `/apk/` (§8.5, §10.5, §10.5.2, `infra/README.md` steps 4 and 4a, `infra/npm/kvitt.conf`; checked with nginx 1.31.7, 17 requests). D-131: `web/` stack (Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` and `openapi-fetch`, Recharts, Vitest, ESLint, Prettier, CSS modules with design-spec tokens, JSON dictionaries), the money functions passing the vectors, the web gate `npm run check` (§11, §15.3). D-132: the web client is built by a Node stage of `server/Dockerfile` (context the repository root) and served by `kvitt-server` under `/app/` from `KVITT_WEB_DIR` with an SPA fallback; immutable hashed assets, `no-store` `index.html`; the `/app/` CSP through `$kvitt_csp` in NPM (§10.3, §10.5.2, §10.6, §15.4). D-133: advanced analytics on the server under `/analytics/*` (per-category averages and trends, a two-month forecast per account = recurring templates due + the 3-month average of non-recurring spend), for the web and the app; AC-14.1 and G-5 revised (§15.8; endpoints D-135 to D-138, v0.29). D-134: plans W0 to W7 with disjoint paths (§11.1, §11.2, §11.3, §14.7, §15.9; `PLAN-W0.md` to `PLAN-W7.md`, `prompts/W0.md` to `prompts/W7.md`). The change log becomes §16. |
| 0.29 | 2026-10-21 | The server side of the web client programme (W7, `50a0d67`) and the unattended APK deploy (`70cb25c`). D-135 web serving: `KVITT_WEB_DIR` (default `/app`); `GET /app` → 308 `/app/`; files from the directory, every other path `index.html`, a missing `assets/*` 404; `assets/*` `public, max-age=31536000, immutable`, `index.html` `no-store`, others `no-cache`; the `$kvitt_csp` web policy plus `nosniff`, `DENY`, `no-referrer`; safe paths at most 512 bytes and 8 segments of `[A-Za-z0-9._-]` not starting with `.`; no directory → `/app` 404 (§10.4, §15.4, AC-21.1). D-136: `/app`, `/app/`, `/app/*` count against the unauthenticated 60/min per IP, bearer token or not (§8.6). D-137 image build: repository-root context with an allowlist `.dockerignore` (`server`, `web`, `contracts`), `server/.dockerignore` removed; a Node `node:24.22.1-bookworm-slim` stage pinned by digest on the build platform, its `dist` passed through the Rust stage so npm and rustc never run together on the Pi 3; workflow context `.`, sparse checkout and path filter add `web` and `contracts`; `infra/build-images.sh` and `infra/dev/build.sh` follow; `Cargo.lock` bumps `rustls-webpki` 0.103.16 and `webpki-root-certs` 1.0.10 (RUSTSEC-2026-0382, -0383) (§10.3). D-138 analytics: E-34 `GET /analytics/trends` and E-35 `GET /analytics/forecast`, authenticated, read-only, with examples; rules in new §14.5.7 (`kvitt-domain::analytics`) (§7, §14.3.4, §14.3.5, AC-14.1, §15.8). D-139 (`70cb25c`): `infra/publish-apk.sh --deploy` copies the APK and `latest.json` with a forced-command key and checks `/apk/latest` (§10.6). Server tests 299; contracts-check 136. |
+16 -11
View File
@@ -3,16 +3,19 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- State as of 2026-10-21. `main` at `44c8cbb` (SPEC v0.27, D-128), after `12e2508` (S-12 line chart, D-128), `b0a90da` (SPEC v0.26, D-127), `05ec3f3` (editor keyboard fix) and `3778f5f` (pull-to-refresh, D-127); `origin/main` (Gitea) at `12e2508`. The web-client programme started 2026-10-21: SPEC v0.28 (D-129 to D-134, this docs pass `agent-web-arch`) and the W0 and W7 implementation agents are in flight. Landed since SPEC v0.26: `05ec3f3`, `12e2508` (below).
- State as of 2026-10-21 evening. `main` at `c0f4345` (update-check fix, D-140, its SPEC v0.30 recording pending), after `50a0d67` (W7: web serving under `/app/` and analytics E-34/E-35, D-135 to D-138), `7bddc50` (SPEC v0.28, D-129 to D-134, web client programme) and `70cb25c` (`publish-apk.sh --deploy`, D-139). `origin/main` (Gitea) at `50a0d67`: that push touches `server/**` and `contracts/**`, so it starts a Pi image build that fails at the Node stage until `web/package.json` lands with W0 (expected and harmless; the Pi keeps running `v1.0.1`). This docs pass (`agent-w7-docs`) records W7 and D-139 as SPEC v0.29. Running: W0 (web foundation), W6 (Android Insights), and the docs for the update-check fix (D-140, v0.30). W1 to W5 start after W0 lands.
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
- **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3.
- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust were already on the Pi; nothing outside that folder is installed or changed. **The stack is running** (owner, 2026-10-21): image `v1.0.1`, `server` and `backup` both healthy after a `chown` of `data/` (it had been created as root); compose's `mem_limit` warning (the Pi kernel has no memory cgroup, so the 160 MiB and 96 MiB limits are not enforced) is accepted. `/health` through NPM from outside answered `"status":"ok"` (public health OK). **Live** (2026-10-21): both phones are paired against it and the Splitwise import (M11) is done.
- **App gate** (D-126, owner decision 2026-10-21): outside the home LAN `192.168.1.0/24` NPM closes (`return 444`) every request without `X-Kvitt-App: <key>`, every route included; the key is the build property `kvitt.appKey` (`android/local.properties`, git-ignored), generated by the owner with `openssl rand -hex 32` and pasted into NPM's block (`infra/npm/kvitt.conf`, `7521c01`); the server and `contracts/openapi.yaml` are unchanged. Android side `34e5cad`, docs `2acfc34` (SPEC v0.25, `infra/README.md` step 4a). `android/local.properties` has a `kvitt.appKey` and `infra/pi/out/apk/` holds `kvitt-125.apk` (checked 2026-10-21); whether both phones run a keyed APK and the gate is in NPM is not recorded here (orchestrator confirms with the owner, §3).
- **Web client programme** (owner interview 2026-10-21, SPEC v0.28, D-129 to D-134, SPEC §15, FR-21): a React web client in `web/`, served by `kvitt-server` under `/app/` from the server image, with the app's features except the phone-only ones, plus search, CSV export, keyboard shortcuts and a language choice; login with an invite code (device `Web · <browser>`); the NPM gate also passes `/app/`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/`, `/apk/` (new block in `infra/npm/kvitt.conf`, README step 4, SPEC §10.5.2; checked with nginx 1.31.7, 17 requests); advanced analytics on the server (`/analytics/*`, D-133, forecast = recurring templates due + 3-month average of non-recurring spend). Plans `PLAN-W0.md` to `PLAN-W7.md`, prompts `prompts/W0.md` to `prompts/W7.md`. W0 (`web/**`) and W7 (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; D-135 to D-138, SPEC v0.29) are running; W1 to W5 start in parallel when W0 has landed (each in its own `web/src/features/<name>/`); W6 (Android Insights) after W7 lands and is deployed. The web gate is `npm run check` in `web/` (third gate; `CLAUDE.md` "Build and verify" still to be updated by the orchestrator).
- **Web client programme** (owner interview 2026-10-21, SPEC v0.28, D-129 to D-134, SPEC §15, FR-21): a React web client in `web/`, served by `kvitt-server` under `/app/` from the server image, with the app's features except the phone-only ones, plus search, CSV export, keyboard shortcuts and a language choice; login with an invite code (device `Web · <browser>`); the NPM gate also passes `/app/`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/`, `/apk/` (new block in `infra/npm/kvitt.conf`, README step 4, SPEC §10.5.2; checked with nginx 1.31.7, 17 requests); advanced analytics on the server (`/analytics/*`, D-133, forecast = recurring templates due + 3-month average of non-recurring spend). Plans `PLAN-W0.md` to `PLAN-W7.md`, prompts `prompts/W0.md` to `prompts/W7.md`. W7 landed `50a0d67` (D-135 to D-138, SPEC v0.29: `/app/` from `KVITT_WEB_DIR`, E-34 `GET /analytics/trends`, E-35 `GET /analytics/forecast`, rules SPEC §14.5.7, the Node stage and the repository-root build context); W0 (`web/**`) is running; W1 to W5 start in parallel when W0 has landed (each in its own `web/src/features/<name>/`); W6 (Android Insights) is running; it shows on the phones only once an image with W7 (which needs W0) is deployed. The web gate is `npm run check` in `web/` (third gate; `CLAUDE.md` "Build and verify" still to be updated by the orchestrator).
- **First release APK**: `apk/kvitt-123.apk` (10.0 MB, git-ignored by `*.apk`) handed to the owner from the main checkout for sideloading on both phones (both paired, 2026-10-21); it was built before any `kvitt.appKey` existed, so it sends no `X-Kvitt-App`: it works only until the gate is pasted (on the home Wi-Fi it keeps working afterwards). The phones need a keyed APK before the gate goes in (§3).
- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`.
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`.
- Landed since SPEC v0.28 (recorded in v0.29):
- W7 server web serving and analytics (`50a0d67`, D-135 to D-138): `KVITT_WEB_DIR` (default `/app`) served under `/app/` (308 from `/app`, SPA fallback, missing `assets/*` 404, `assets/*` cached a year immutable, `index.html` `no-store`, the `$kvitt_csp` web policy plus `nosniff`, `DENY`, `no-referrer`, strict path allowlist); `/app` paths on the unauthenticated 60/min per IP (D-136); image build from the repository root with an allowlist `.dockerignore`, a Node 24 LTS stage pinned by digest passed through the Rust stage (D-137); E-34 trends and E-35 forecast with examples, rules in `kvitt-domain::analytics` (D-138); `Cargo.lock` `rustls-webpki` 0.103.16, `webpki-root-certs` 1.0.10 (RUSTSEC-2026-0382, -0383). 299 server tests; contracts-check 136.
- Unattended APK deploy (`70cb25c`, D-139): `infra/publish-apk.sh --deploy` copies the APK, then `latest.json`, with the forced-command key `infra/secrets/pi_deploy_ed25519` and checks `/apk/latest` with `X-Kvitt-App`; first run 2026-10-21 served `version_code` 133.
- Landed since SPEC v0.26 (recorded in v0.27):
- S-12 line chart (`12e2508`, D-128, owner request 2026-10-21): S-12 opened from a category or account breakdown row has its own period chip and Month/Week toggle (starting from S-11's, changing S-12 only; measure and group-by fixed from S-11), the header total, a "Utgifter över tid" card with `SpendLineChart` (one point per bucket, empty as zero), the dashed range average with its amount, a trailing 3-bucket moving average, `LineLegend` and a caption comparing the latest whole bucket with the average ("<Bucket>: N % över snittet" / "under snittet" / "på snittet"), then the rows; a bar drill-down is unchanged. New AC-14.11; seven `strings.csv` keys (569 rows); no model, domain, fakes, server or contract change. Goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants` new, `s12_category`, `s12_account`, `s12_long` re-recorded; 977 Android tests.
- Editor keyboard fix (`05ec3f3`, no decision): S-19, S-15 and S-6 editor bodies apply the scaffold padding, consume it and add `imePadding()`, so the keyboard no longer covers a focused field.
@@ -50,19 +53,21 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
- In flight:
- This docs pass (`agent-web-arch`: SPEC v0.28, D-129 to D-134, §15 Web client, FR-21, `infra/npm/kvitt.conf`, `infra/README.md` steps 4 and 4a, `PLAN-W*.md`, `prompts/W*.md`, this file).
- W0 web foundation (`web/**`) and W7 server web serving and analytics endpoints (D-135 to D-138, SPEC v0.29), in parallel.
- This docs pass (`agent-w7-docs`: SPEC v0.29, D-135 to D-139, `infra/README.md` build context, this file).
- W0 web foundation (`web/**`); W6 Android Insights (D-141 on); the update-check fix's docs (D-140, SPEC v0.30; code landed `c0f4345`).
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
- Remote: `origin` on Gitea, SSH key works; pushed at `7521c01`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 977 tests at `12e2508`; server 276 tests at `c9abd24`). A third gate comes with W0 (D-131): `npm run check` in `web/` (`tsc --noEmit`, `eslint .`, `vitest run`, `vite build`, generated-types freshness); a change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing.
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 977 tests at `12e2508`; server 299 tests at `50a0d67`). Known red: since `50a0d67` the Android gate on `main` fails three `:data` contract tests (`ExampleCoverageTest.everyExampleFileHasADto`, `ExampleRoundTripTest` for `E-34-analytics-trends/response.json` and `E-35-analytics-forecast/response.json`) until W6's E-34/E-35 DTOs land. A third gate comes with W0 (D-131): `npm run check` in `web/` (`tsc --noEmit`, `eslint .`, `vitest run`, `vite build`, generated-types freshness); a change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing.
- Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The production import on the Kvitt Pi is done (owner, 2026-10-21). The CSV files stay in `export/` (git-ignored).
- Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`.
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; paired against the Kvitt Pi 2026-10-21. The partner's phone is paired too.
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
## 2. Open work (one line each, with who owns it: agent or owner)
- `agent-catline-docs` landed as `44c8cbb` (SPEC v0.27, D-128); push it to `origin` if not done (it touches no `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run). Land `agent-web-arch` (orchestrator): SPEC v0.28, D-129 to D-134, `infra/npm/kvitt.conf`, `infra/README.md`, `PLAN-W*.md`, `prompts/W*.md`, HANDOFF; docs only, no gate but `python3 tools/contracts-check.py`; pushing it triggers no workflow run.
- Land `agent-w7-docs` (orchestrator): SPEC v0.29, D-135 to D-139, `infra/README.md`, HANDOFF; docs only, gate `python3 tools/contracts-check.py`; pushing it triggers no workflow run.
- Android gate red on `main` since `50a0d67` (three `:data` example tests for E-34/E-35, §1); W6 fixes it with the DTOs (agent W6).
- Pi image builds fail at the Node stage until W0 lands `web/package.json` (expected); the first image with the web client follows W0, then deploy (§10.10) and HUMAN step 3.
- Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min).
- Pushed: through `12e2508` (S-12 line chart, D-128).
- Closed by `2acfc34` (`agent-gate-docs`): `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered).
@@ -79,12 +84,12 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`.
- Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/<id>.md`:
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124), pull-to-refresh (D-127), the editor keyboard fix, the S-12 line chart (D-128): landed.
- Web client programme (D-134, SPEC §11.2 rows W0 to W7): W0 and W7 running; then W1 to W5 in parallel after W0 (briefs from `prompts/W1.md` to `prompts/W5.md` with W0's module map in the notes; if W0 lacks a shared piece the plans name, a W0b follow-up first); W4's W-11 and W6 after W7. Docs agent afterwards: record W0's and W7's reports (D-135 to D-138, SPEC v0.29, `KVITT_WEB_DIR` in §10.4, §7 rows for `/analytics/*`, the README's workflow and build-images sections for the repository-root context).
- Web client programme (D-134, SPEC §11.2 rows W0 to W7): W0 and W7 running; then W1 to W5 in parallel after W0 (briefs from `prompts/W1.md` to `prompts/W5.md` with W0's module map in the notes; if W0 lacks a shared piece the plans name, a W0b follow-up first); W4's W-11 and W6 after W7. W7's report is recorded (v0.29); a docs agent records W0's report when it lands.
- Next: the app key and gate if not yet done (D-126, §3); M10 First release (keystore per §10.6, D-101; the reports `first-release.md` and `restore-drill.md`). Done 2026-10-21: release pairing of both phones against the Kvitt Pi and M11 Import history (SPEC §10.8 "Production run").
## 3. HUMAN steps pending (relay in chat)
Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy), the public health check through NPM, bootstrap, release pairing of both phones, the Splitwise import.
1. App key (D-126; `infra/README.md` step 4a items 1 to 3; the key and `kvitt-125.apk` exist on the Mac, the rest is unconfirmed): generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload).
1. App key (D-126; `infra/README.md` step 4a items 1 to 3; the key exists on the Mac and the Pi serves the keyed `kvitt-133.apk` since the first `--deploy` run, D-139; whether both phones run it is unconfirmed): generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload).
2. Gate (step 4a items 4 and 5), only after both phones run the keyed APK: NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no).
3. Web client gate lines (D-130, SPEC v0.28): when the NPM block is pasted (item 2, or again if the D-126 block is already in NPM), paste the current `infra/npm/kvitt.conf` content (step 4), which also lets the web client through and sets its Content-Security-Policy; Save (row Online, yes/no). After the first image with the web client is deployed (W7, §10.10), acceptance test with Wi-Fi off on the phone: `https://kvitt.suorra.duckdns.org/app/` shows the Kvitt login page (yes/no) and `https://kvitt.suorra.duckdns.org/health` still gives a connection error (yes/no) (step 4a item 5 d and e).
@@ -98,9 +103,9 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
- Nothing.
## 6. Numbering
- Decisions: D-001 to D-134 used, except D-008 and D-009 (never used); D-135 to D-138 reserved for W7 (the server agent); D-139 taken by `infra/publish-apk.sh --deploy` (`70cb25c`, cited in `infra/README.md`, not yet in `docs/decisions.md` or SPEC: a docs agent records it). Next free: **D-140**.
- Spec version: v0.28 written (web client programme, after v0.27 for D-128); v0.29 reserved for W7's contract change. Next **v0.30** (D-139's recording may take it).
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.27 and v0.28 (W7 expects no migration).
- Decisions: D-001 to D-139 used, except D-008 and D-009 (never used); D-140 taken by the update-check fix (`c0f4345`, recording in progress). Next free: **D-141** (W6's range starts there).
- Spec version: v0.29 written (W7 and D-139); v0.30 reserved for D-140. Next **v0.31**.
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Unchanged by v0.27 to v0.29 (W7 needed no migration).
- Sync protocol **1**, push payload **1**.
## 7. How the work is run
+6 -1
View File
@@ -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" |
+4 -3
View File
@@ -53,9 +53,10 @@ infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile
infra/dev/build.sh backup # only kvitt-backup:dev
```
The server build is `docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server`
(override the context with `KVITT_SERVER_BUILD_CONTEXT`). The script refuses to run while a Gradle build holds
`/tmp/kvitt-gradle.lock` (CLAUDE.md).
The server build is `docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev .` from the
repository root, the build context since D-137 (the root `.dockerignore` is an allowlist that lets only `server/`, `web/`
and `contracts/` in; the image builds the web client too); override the context with `KVITT_SERVER_BUILD_CONTEXT`.
The script refuses to run while a Gradle build holds `/tmp/kvitt-gradle.lock` (CLAUDE.md).
### Start, stop, logs