diff --git a/SPEC.md b/SPEC.md index c15c105..8922e65 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.32** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**. +Version **0.33** (2026-10-24). 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. @@ -132,7 +132,7 @@ The server starts with an empty database. The household is created from the comm ### FR-3 Devices and revocation * AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one. -* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. +* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. A member who names a device of the other user gets `not_found`, the same answer as for an unknown id, so the answer does not tell whose the device is (D-156, security review L-10; to implement: the server answers `forbidden` there as of v0.33). * AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button. * AC-3.4 A revoked device never receives pushes again. * AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at` and clears the device's `fcm_token` (D-034). @@ -282,7 +282,7 @@ The server starts with an empty database. The household is created from the comm * AC-16.1 The server hosts the latest signed release APK and its metadata (§10.6). * AC-16.2 Release builds call `GET /apk/latest` on every cold start and at most once per 6 hours while resumed (the manual check of AC-16.6 skips this throttle, D-140). When `version_code` is greater than the installed one, Home shows an "Update available" banner with the version name. -* AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt. +* AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt. On Samsung phones Auto Blocker must be off for the install: with it on, the system installer refuses the APK (observed on both phones, 2026-10-24: turning it off let the update install; D-156). The owner turns it off for an update and back on afterwards; the D-155 blocked text (`update_install_blocked`) names Auto Blocker (AC-16.7). * AC-16.4 A verification failure deletes the file and shows "Download failed, try again". * AC-16.5 Debug builds never check for updates. * AC-16.6 "Check for update" on S-22 calls `UpdateRepository.checkNow()` (D-140; `check()` before v0.30), which returns an `UpdateCheck` (D-092, revising D-073): `UpToDate` (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"); `Failed` (no answer or another error) reports "Could not reach the server" (`common_could_not_reach_server`); `Available` shows the update banner. Debug builds report `UpToDate` without a request (AC-16.5). S-22 "Check for update" always calls `GET /apk/latest`, whatever the 6-hour throttle of AC-16.2 says, and restarts that window; the automatic checks on cold start and resume keep the throttle (D-140). @@ -299,7 +299,7 @@ The server starts with an empty database. The household is created from the comm ### FR-18 Backups and restore -* AC-18.1 Every night at 03:30 Europe/Stockholm the backup sidecar writes a consistent SQLite snapshot and a pictures archive, gzips them and uploads them to the owner's Google Drive folder with rclone. Nothing on Drive is ever deleted or overwritten. +* AC-18.1 Every night at 03:30 Europe/Stockholm the backup sidecar writes a consistent SQLite snapshot and a pictures archive, gzips them and uploads them to the owner's Google Drive folder with rclone. Nothing on Drive is ever deleted or overwritten. Verified by the owner on 2026-10-24: the first backup on the Kvitt Pi ran and its snapshot pair is in the Drive folder (D-156, §11.4). * AC-18.2 A restore drill following §10.9 restores a backup on the Pi and both phones then sync correctly, documented in `docs/validation/restore-drill.md`. ### FR-19 Localisation and formatting @@ -603,7 +603,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM | Edit the other user's display name or picture | no | no | | Create, list, cancel invites | yes | no (`forbidden`) | | List devices | all | own only | -| Revoke a device | any except current | own except current | +| Revoke a device | any except current | own except current (another user's device: `not_found`, D-156) | ### 8.5 Transport @@ -922,6 +922,8 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it * No named volumes: `data` and `backups` are the bind mounts `./data` and `./backups` (D-125; were the named volumes `data` and `backups`; `certs` and `caddy_data` were removed by D-120). * Both services: `user: "${KVITT_UID}:${KVITT_GID}"`, the owner's uid and gid (1000:1000 on the Pi); `read_only: true` with a 16 MB tmpfs `/tmp`; `cap_drop: [ALL]`; `no-new-privileges`; `restart: unless-stopped`; logging driver `json-file` with `max-size: 10m`, `max-file: 3`; `TZ=Europe/Stockholm`. * The compose default network (no network is declared; was the bridge network `kvitt`); only `server` publishes a port. +* No host firewall limits the published port 8080 to `HA_HOST`, so any device on the home LAN can reach the server over plain HTTP; the owner deferred such a rule on 2026-10-24 (D-156). It would be the owner's own step on the Pi, outside `~/kvitt` (D-125). +* The Memory limit column is advisory on the Kvitt Pi (D-156, owner 2026-10-24): `compose.yaml` keeps its `mem_limit` lines, but the Pi's kernel runs without the memory cgroup, so Docker warns at `docker compose up` and does not enforce the limits. The warning is accepted; the RSS budget is checked with `docker stats` (§10.10). * One uid for both containers (D-105, revised by D-125): `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns, so `backup` runs as the server's uid. On the Pi that uid is the owner's own, so every file in `data/` and `backups/` belongs to the owner, the two secret files are the owner's with mode 0600 (`chmod 700 secrets && chmod 600 secrets/*`), and a restore moves files with plain `mv` and `cp` (§10.9). The images need no particular uid: the server image's `USER 10001` is only its default, which compose's `user:` overrides (the binary needs no account and nothing writable outside `/data`), and supercronic runs as any uid; the owner's uid has no account inside either image, so `backup` gets `HOME=/tmp` for rclone's cache. rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a private copy in `/tmp` on every run (D-105). * Each container mounts only its own secret file, read-only (D-117): the server never sees the Drive token. `create_host_path: false` makes a missing secret file stop `docker compose up` with "bind source path does not exist" instead of Docker creating a root-owned directory in its place. These two are the only secret files on the Pi since D-120. * The development stack (`infra/dev/compose.yaml`) is unchanged by D-125: both containers there run as uid 10001 (§10 intro). @@ -933,6 +935,7 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it * Image names (D-104, D-120): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). * 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..` 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. +* Where images are built (owner, 2026-10-24, D-156): on the Pi 3 runner through the workflow above for now; whether to move the builds is decided after the result of the run then in progress, and `infra/build-images.sh` stays the fallback. The runner Pi's disk (15 GB) reached 11 GB during builds, so the owner runs `docker builder prune -af` on it between builds; that can drop the BuildKit cache later runs reuse, so the next server build may be a full one. * 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:-bookworm`, where `` 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`. @@ -1385,8 +1388,8 @@ After M1, M2a and M2b land, these run in parallel without touching each other's Reports in `docs/validation/` are deliverables (`CLAUDE.md`); they carry aggregates only and record the commit they were taken at. The release reports `first-release.md` and `restore-drill.md` (M10, AC-18.2) are still to be written. -* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a `KvittWorkerFactory` test. -* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed, `725901c`). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings. +* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-20.1 observed on the Pi (M10), and a `KvittWorkerFactory` test. Observed 2026-10-24 (D-156): AC-18.1, the first backup on the Pi with its snapshot pair in the Drive folder (verified by the owner); AC-16.3, the real `PackageInstaller` install on both phones (release 140), once Samsung Auto Blocker was off; the editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) on the device. +* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device): owner answer 2026-10-24, `not_found` (D-156; to implement, E-16). Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed, `725901c`). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings. --- @@ -1401,7 +1404,7 @@ Only things that cannot be decided without the owner. Everything else in this sp | OQ-3 | Answered by placeholders (D-100, revised by D-120): the LAN address of the Kvitt Pi (``; its `.env` key `KVITT_PI_HOST` was dropped in `200c99f`) and the LAN IPv4 address of the Home Assistant Pi, where NPM runs (`HA_HOST`); with D-120 also the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF). ~~SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there; which SSH add-on is used~~ (cert-sync removed by D-120). The owner fills `HA_HOST` in the Pi's `.env` (D-125) and uses the Kvitt Pi's address in NPM and in `scp`; no agent needs them. | M2c-pi | answered | | OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered | | OQ-5 | Answered 2026-10-18 and implemented (D-099, `a0c25b0`): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. | M10 | answered | -| OQ-6 | Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. | M10 | Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116) | +| OQ-6 | Answered 2026-10-24 (D-156): no confirmation step on S-1 for an invite link that prefills a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6); the default stands: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116). | M10 | answered | --- @@ -2379,7 +2382,7 @@ The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "http **E-15 `GET /devices`** (device). 200 `{ "devices": [Device, ...] }` ordered by `created_at`, revoked included (with `revoked_at`). -**E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `forbidden`, `not_found`, `cannot_revoke_current_device`. +**E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `not_found` (an unknown id, or, for a member, a device of the other user: D-156, security review L-10), `cannot_revoke_current_device`. To implement (D-156): the server still answers `forbidden` for a member naming the other user's device, and `contracts/openapi.yaml` still lists 403 for E-16; the change drops both together (a contract change, §14.8). **E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). A string must be non-empty: `""` is `invalid_request` (D-034); more than 512 characters after trimming is `invalid_request` with field `fcm_token` too (D-114, security review L-4). 204. Errors: `invalid_request`. @@ -3005,6 +3008,7 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w * **Login.** A browser logs in with an invite code exactly like a phone pairs: E-3 `POST /invites/accept` with a `device` invite (the usual case: the owner creates one on S-21 or W-18 for himself or the partner), or a `partner` or `admin_setup` invite. The device name is `Web · ` (the browser's name from the user agent, `Web` alone when unknown). `display_name` is sent when the login page's name field is filled; it is needed only for `partner` and `admin_setup` codes (E-3), and an `invalid_request` for a missing name shows the field's error. A pasted invite link (`https:///i/[?from=…]`) fills the code (§8.3 normalisation, as S-1). After a `partner` accept the web opens the profile page once (the S-2 step, AC-2.10); every other accept opens Home. * **Token.** The bearer token is kept in the browser's `localStorage` and sent as `Authorization: Bearer ` on every API call (§8.2). It is never put in a URL, never logged, never shown. The browser is a device like a phone: listed on Devices (AC-3.1) with its name, revocable from another device like any device (AC-3.2), and a revoked or unknown token gets 401 (AC-3.3), on which the web forgets the token and shows the login page with the S-18 message (`device_revoked`) or the plain login page (`unauthorized`). * **Log out** (W-13) forgets the token in this browser. The device stays on the Devices list, still valid, until it is revoked from another device, because a device cannot revoke itself (`cannot_revoke_current_device`, AC-3.2); the page says so. A sign-out endpoint that revokes the current device would be a contract change (§14.8) and is not part of v0.28. +* **First use** (owner, 2026-10-24, D-156): the web client is first used from a browser on the owner's laptop; the phones keep the app. * **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. @@ -3128,3 +3132,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0 | 0.30 | 2026-10-21 | The manual update check (`c0f4345`). D-140 (owner, 2026-10-21): S-22 "Check for update" shared the throttled automatic check, so a tap within 6 hours of the last automatic check reported the stored answer (the owner deployed a new APK and was told "You have the latest version"); `UpdateRepository.checkNow()` (default implementation calls `check()`, so fakes are unchanged) always asks the server and restarts the 6-hour window, debug builds still return `UpToDate` without a request; `AboutViewModel.check()` calls `checkNow()`; the automatic checks on cold start and resume keep the throttle (AC-16.2, AC-16.6, §13.3 S-22, §14.2.4). | | 0.31 | 2026-10-21 | Android Insights on S-11 (W6, `1c9856b`; Android gate green, which also fixes the E-34/E-35 example tests red since `50a0d67`). D-141 UI contract: `:core:model` `InsightsRepository` with `trends(months: 1..24, measure)` and `forecast(measure)`, both `OnlineResult` (`Offline` without a request, `Failed("network")` when unreachable); types `SpendTrends`, `SpendTrend`, `TrendPoint`, `CategoryTrend` (`category: Category?`), `SpendForecast`, `AccountForecast` (`account: Account?`), `ForecastMonth`, named after the E-34/E-35 fields; names resolved from Room, archived and deleted included, unknown id → null; nothing stored; `:data` `AnalyticsDtos.kt`, `InsightsRepositoryImpl`, two `KvittApi` calls; fakes `FakeInsightsRepository`, `InsightsScenarios`, `insightsFailure` (§14.2.4). D-142 placement and loading: an "Insights" ("Insikter") section below S-11's local analytics, following S-11's measure toggle, always 6 whole months (E-34's default); loads when S-11 opens, when the measure changes to one not yet loaded, when the phone comes online and on "Försök igen"; data kept in memory per measure while S-11 is open; the last data stays visible under the offline or error banner; S-11's offline analytics unchanged (AC-14.1) (§13.3 S-11, design-spec S-11). D-143 display rules: the household chart and a per-category chart on D-128's `SpendLineChart` with the server's average and moving average; categories in the server's order, the open one defaults to the largest total; unknown category ids skipped; one forecast card per account (archived badge) with the current month so far and two months as "Recurring + average = forecast"; an all-zero card shows one line "No recurring expenses and no recent spending" (design-spec S-11, §3.10). `strings.csv` 588 rows (16 `analytics_insights_*` keys, 19 rows) (§13.4). Goldens `s11_insights_` for 13 states in three variants plus `s11_insights_long_sv_light_200`. | | 0.32 | 2026-10-21 | The web feature plans W1 to W5 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; web gate on `main` 59 files, 532 tests at `1aecf51`, 598 at `83f5293` with the `suggest_category` vectors) and the update install-failure fix (`d686ee0`). D-144: the preset account travels as `?account=` for `/app/expenses/new` and `/app/settle`, a payment being edited as `/app/settle?settlement=`; builders in `features/accounts/links.ts` and `features/settle/links.ts` until W0 adds them to `routes.ts`. D-145: web Home's empty state shows the "Du och är med" card in place of the balance header; the archived section's open state lasts for the page session in memory. D-146: CSV export format (UTF-8 with BOM, `;`, CRLF, RFC 4180 quoting, "1234,50" in every language, ISO dates, header in the page language, one share column per user, formula cells prefixed `'`, `kvitt-utgifter-.csv` / `kvitt-expenses-.csv`). D-147: AC-8.6's "on this phone" is this browser (`localStorage`); search filters in the URL (amounts in öre). D-148: web expense editor: rejections and failures in a banner with S-16's text and "Visa nuvarande version", the server's row into the snapshot, typed values kept; "Utgiften sparades" after edits too; the discard dialog on `Esc` and in-app navigation; browser controls in place of S-7's keypad, ratio slider and summary row; shared fields in `web/src/components/editor/`; the keyword table imported raw (`server.fs.allow`). D-149: W-7 shows the §14.5.3 allocation errors as you type. D-150 unused. D-151: a bar opens `/app/analytics/bucket/`; the filter in the query string (`range`, `from`, `to`, `interval`, `measure`, `group`, `bucket`), replaced by W-10's own period changes. D-152: chart SVGs hidden from screen readers, bar labels as keyboard stops, "Visa som tabell"; W-11 offers 3, 6 or 12 months. D-153: W-18's route is `/app/settings/invite` (`/invites` still opens it); W-15 and W-16 editor routes. D-154: the partner's first-login step is an intro on W-14 remembered in `kvitt.profileStepDone` (the device id); W-19's fallback reads the server-version header of a fresh `GET /me` (§15.3, §15.6, §15.7, AC-8.6). D-155 (`d686ee0`): a refused or failed install shows "Kunde inte installera uppdateringen" with the reason (blocked by the security settings, or the installer's message) and "Försök igen", which downloads again; a declined dialog returns to `Available`; `UpdateState.Failed` gains `reason: UpdateFailure` (`Download`, `Blocked`, `Install(detail)`) (new AC-16.7, §13.3 S-17, §14.2.4; design-spec §3.6, S-17, S-22). `strings.csv` 590 rows (`update_install_failed`, `update_install_blocked`). Goldens `S-17-install-blocked`, `S-17-install-failed`, `s22_about_install_blocked` new, `update_banner` re-recorded. contracts-check 136. | +| 0.33 | 2026-10-24 | Owner answers and phone observations of 2026-10-24 (D-156) and the small-edits working rule (D-157). D-156: OQ-6 answered, no confirm step on S-1 for an unusual server (§12); security review L-10 answered, a member naming the other user's device gets `not_found` (AC-3.2, §8.4, E-16; to implement, the server answers `forbidden` today); no host firewall rule limiting port 8080 to `HA_HOST` for now (§10.2, deferred); `mem_limit` kept and advisory on the Pi (§10.2); images keep being built on the Pi 3 runner until the current run's result, `infra/build-images.sh` the fallback, `docker builder prune -af` between builds (runner disk 11 of 15 GB, §10.3); Samsung Auto Blocker must be off for the in-app install (AC-16.3); AC-18.1 verified by the owner, AC-16.3 and the editor keyboard fix observed on the phones, both phones on release 140 (AC-18.1, §11.4); the web client is first used from the laptop browser (§15.2). D-157: small edits by a Haiku or Sonnet agent directly in the main checkout, committed on `main` by the orchestrator (working rule, `CLAUDE.md`). `infra/README.md` "Pi: what the owner does" gains the prune and Auto Blocker notes. contracts-check 136. | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 6370cbf..1380c7b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,14 +3,15 @@ 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, night. `main` at `83f5293` (web fix: deterministic feature strings, `suggest_category` vectors, raw bodies in the fake server), after the five web feature plans W1 `934eee7`, W3 `f9a9bfb`, W5 `3ebe57d`, W2 `a65ab71`, W4 `1aecf51` (D-144 to D-154; D-150 unused) and `d268dd0` (`.gitignore` anchors `export/`); before them `aaa09d9` (SPEC v0.30, v0.31), `d686ee0` (APK install failures visible, D-155) and `1c9856b` (W6). All five web plans are landed and pushed (orchestrator). The web gate is green on `main` (59 test files, 532 tests at `1aecf51`; 59 files, 598 tests at `83f5293` with the `suggest_category` vectors, run by this docs pass). This docs pass (`agent-docs-v32`) records D-144 to D-155 as SPEC v0.32. The Pi image build with the complete web app is in progress on the Pi; the owner deploys it (§3 step 4), then checks the web client in a browser (§3 step 5). Release APK `kvitt-140` deployed 2026-10-21 (`infra/publish-apk.sh --deploy`): install-failure messages (D-155) and S-11 Insights, which need the new server image (E-34, E-35). +- State as of 2026-10-24. `main` at `c13be3b` (SPEC v0.32: web plans W1 to W5, D-144 to D-154, APK install failures, D-155), pushed to `origin` 2026-10-24. Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones (verified 2026-10-24). Waiting for the Pi image with the complete web app (the Gitea workflow run in progress on the Pi runner); then the owner deploys it (§3 step 3), logs in from the laptop browser (the web client's first use, D-156) and runs the browser checks (§3 step 4). This docs pass (`agent-docs-v33`) records the owner's answers and observations of 2026-10-24 as D-156 and the small-edits working rule as D-157 (SPEC v0.33). Before it: the five web plans W1 `934eee7`, W3 `f9a9bfb`, W5 `3ebe57d`, W2 `a65ab71`, W4 `1aecf51`, the web fix `83f5293`, `d686ee0` (D-155) and `1c9856b` (W6); the web gate is green on `main` (59 files, 598 tests at `83f5293`). +- **Verified 2026-10-24** (owner, D-156): Samsung Auto Blocker was the cause of the silent update failure; turning it off let the update install, and the owner turned it back on, so every in-app update on the Samsung phones needs it off (the D-155 blocked text names it; SPEC AC-16.3, `infra/README.md`). The editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) works on the device. The first backup on the Kvitt Pi ran and a snapshot pair is in the Drive folder (AC-18.1). The runner Pi's disk reached 11 of 15 GB during image builds; the owner runs `docker builder prune -af` between builds (SPEC §10.3). - **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: `, 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). +- **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 and the lines stay (D-156, SPEC §10.2). `/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: `, 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); both phones run release 140, built with the key like every APK since `kvitt-133` (2026-10-24); whether 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 · `); 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 landed `2ae65a5` (`web/**`: Vite, React 19, TypeScript strict, sync-pull ledger, invite-code login, ported money rules passing the vectors, tokens, i18n generated from `docs/ux/strings.csv` and `web-strings.csv`); W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, SPEC v0.32), each in its own `web/src/features//`, W2 with the shared editor fields in `web/src/components/editor/`; loose ends for a W0b plan in §2 "Web follow-ups"; W6 (Android Insights) landed `1c9856b` and shows on the phones once the image with W7 and W0 is deployed and a release APK with W6 is published. The web gate is `npm run check` in `web/` (third gate, in `CLAUDE.md` since `415f9f5`). -- **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). +- **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). Both phones have since moved to the keyed release 140 (2026-10-24), so the gate can go in (§3 step 1). - 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.31 (recorded in v0.32): @@ -62,14 +63,16 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Server gap pass (`8660b7f`, D-113): `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin), so a scripted partner needs no second admin device; `push sent` info line; CLI tests (`invite-partner`, `migrate`, `healthcheck`, SIGTERM drain); `tools/contracts-check.py` 134 checks. - Infra security (`7fb86f5`, D-117): Caddy log redaction of `/i/` and query strings, CSP and three more response headers, Caddy non-root (uid 10002, read-only, caps dropped), `/certs` 0750, dev stack mounts only the FCM key, `/opt/kvitt/deployed-digests` with a WARNING on change, `kvitt_cicd` with `write:package` only, the plain root key (old variant B) not acceptable. - M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116): S-16 lost version read-only, S-22 " (schema )" from `/health`, `SettlementRepository` returns null instead of throwing, E-17 retry after each successful sync, invite links dropped while paired, private notifications on the lock screen, `localeFilters` en/sv, `:data` tests (429, D-040, WorkManager via `work-testing`), the home/account screenshot harness fix (stale `sv_dark` goldens re-recorded), 19 unused string keys removed (`strings.csv` 562 rows). Gate at landing: 941 tests. Release APK 10.0 MB. -- 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`). +- 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 answered 2026-10-24: `not_found` for the other user's device (D-156; to implement, §2). 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-docs-v32`: SPEC v0.32, D-144 to D-155, design-spec §3.6, S-17, S-22, this file). - - The Pi image build with the complete web app (in progress on the Pi), then the owner's deploy and browser checks (§3 steps 4 and 5). + - This docs pass (`agent-docs-v33`: SPEC v0.33, D-156, D-157, `infra/README.md`, this file). + - The Pi image build with the complete web app (in progress on the Pi runner), then the owner's deploy and browser checks (§3 steps 3 and 4). + - `CLAUDE.md` small-edits rule (D-157), edited in the main checkout by a small agent. + - W0b web cleanup (agent in `web/`, the follow-ups below). - 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). +- Remote: `origin` on Gitea, SSH key works; pushed at `c13be3b` (2026-10-24). 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 green at `1c9856b`, which fixed the three `:data` example tests red since `50a0d67`; server 299 tests at `50a0d67`). The third gate (W0, D-131): `npm run check` in `web/` (generated files, `tsc --noEmit`, `eslint .`, Prettier, `vitest run`, `vite build`), green on `main` with W1 to W5 (59 files, 598 tests at `83f5293`); 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`. @@ -77,20 +80,20 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - 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) -- Land `agent-docs-v32` (orchestrator): SPEC v0.32, D-144 to D-155, design-spec, HANDOFF; gates `python3 tools/contracts-check.py` and `npm run check` in `web/`. -- Pi image with the complete web app (built from the push of the web plans): watch the workflow run, then the owner deploys it (§3 step 4) and checks the web client (§3 step 5); HUMAN step 3 if the NPM block is not yet current. Release APK `kvitt-140` (W6 Insights, D-155) is already deployed; its Insights need this image. -- W0b web cleanup plan (agent, no plan yet): the "Web follow-ups" below. +- Land `agent-docs-v33` (orchestrator): SPEC v0.33, D-156, D-157, `infra/README.md`, HANDOFF; gate `python3 tools/contracts-check.py`. +- Pi image with the complete web app (built from the push of the web plans): watch the workflow run, then the owner deploys it (§3 step 3) and checks the web client from the laptop browser (§3 step 4); HUMAN step 2 if the NPM block is not yet current. Release 140 (W6 Insights, D-155) runs on both phones; its Insights need this image. +- W0b web cleanup (agent, in progress in `web/`): the "Web follow-ups" below. - 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 the five web plans (orchestrator, 2026-10-21). +- Pushed: `c13be3b` (orchestrator, 2026-10-24). - Closed by `2acfc34` (`agent-gate-docs`): `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered). - `infra/backup/Dockerfile`: stale comment "on the Pi the container runs as uid 10001" (since D-125 the owner's uid); left as is, because editing it triggers a 5-minute image build (agent, with the next change there). - Historical `:8443` and `icehack.asuscomm.com` mentions in `docs/ux/` and `docs/validation/` are left as the record of their time (no action unless the owner wants them rewritten). - Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit); `infra/RESTORE.md` is the runbook since D-125. -- Possible firewall rule on the Kvitt Pi limiting port 8080 to the HA Pi (`HA_HOST`); not done, not decided (owner). D-125 makes no system changes, so it would be the owner's own step. -- AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and the phones (M2c-pi run, M10). +- Firewall rule on the Kvitt Pi limiting port 8080 to the HA Pi (`HA_HOST`): deferred by the owner 2026-10-24 (D-156, SPEC §10.2); it would be the owner's own step, outside `~/kvitt` (D-125). +- Image-build location (owner, after the current run's result, D-156): stay on the Pi 3 runner or move to the Mac fallback `infra/build-images.sh`. +- AC-20.1 shown on the Pi (M10). AC-18.1 and AC-16.3 observed 2026-10-24 (D-156). - A `KvittWorkerFactory.createWorker` test (Android agent, no plan yet). -- Security review L-6: an S-1 design state if the owner wants a confirm step for an unusual server (OQ-6, default not in v1; owner, §4). -- Security review L-10: `forbidden` or `not_found` for the other user's device id (owner decision, §4). +- Security review L-10 (server agent, no plan yet; D-156): `revoke_device` answers `not_found` instead of `forbidden` when a member names the other user's device; drop 403 from E-16 in `contracts/openapi.yaml` and the handler's utoipa annotation together (contract change: spec bump and decision, §14.8); `web/src/api/schema.d.ts` regenerated. - State-class naming: `*Ui`, `*UiState` and `*State` are mixed across features; needs a decision if a convention is wanted (owner; no plan). - Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs). - `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`. @@ -114,26 +117,26 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Closed by `83f5293`: the fake test server keeps non-JSON bodies raw. ## 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 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). -4. Deploy the new image (the complete web app and E-34/E-35, SPEC §10.10), once the Gitea workflow run (in progress on the Pi) has succeeded: on the Kvitt Pi in `~/kvitt`, set `KVITT_TAG` in `.env` to the tag the run printed, then `docker compose pull && docker compose up -d`; `server` and `backup` healthy (yes/no). The release APK `kvitt-140` is already deployed; S-11's Insights work once this image runs. -5. Web client in a browser (after step 4): on the phone, Inställningar › Para ihop en enhet › För mig, create a code; on the Mac open `https://kvitt.suorra.duckdns.org/app/`, "Logga in i Kvitt", type the code, log in: Home shows (yes/no). Then, each yes/no: Home and one account open; add, edit and delete an expense; Sök, export a CSV and open it in Numbers (columns and amounts right); Gör upp: record a payment and delete it; Statistik, open a category row (line chart), Insikter; each Settings page opens; on the phone Inställningar › Enheter shows `Web · `, "Ta bort åtkomst" on it, and the browser shows the login page with the removed-device message. +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. Done 2026-10-24: both phones on release 140 (keyed, so the old step 1 is done), the first backup on Drive (AC-18.1). +Standing note for every in-app update on the Samsung phones: turn Auto Blocker off before tapping "Uppdatera", back on afterwards (D-156). Between two workflow builds on the runner Pi: `docker builder prune -af`. +1. Gate (step 4a items 4 and 5), both phones run the keyed APK (release 140): 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). +2. Web client gate lines (D-130, SPEC v0.28): when the NPM block is pasted (item 1, 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). +3. Deploy the new image (the complete web app and E-34/E-35, SPEC §10.10), once the Gitea workflow run (in progress on the Pi) has succeeded: on the Kvitt Pi in `~/kvitt`, set `KVITT_TAG` in `.env` to the tag the run printed, then `docker compose pull && docker compose up -d`; `server` and `backup` healthy (yes/no). Release 140 runs on both phones; S-11's Insights work once this image runs. +4. Web client in a browser on the laptop (after step 3, D-156): on the phone, Inställningar › Para ihop en enhet › För mig, create a code; on the laptop open `https://kvitt.suorra.duckdns.org/app/`, "Logga in i Kvitt", type the code, log in: Home shows (yes/no). Then, each yes/no: Home and one account open; add, edit and delete an expense; Sök, export a CSV and open it in Numbers (columns and amounts right); Gör upp: record a payment and delete it; Statistik, open a category row (line chart), Insikter; each Settings page opens; on the phone Inställningar › Enheter shows `Web · `, "Ta bort åtkomst" on it, and the browser shows the login page with the removed-device message. ## 4. Owner questions (asked, unanswered) -- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". -- Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`. +- Firewall rule limiting the Kvitt Pi's port 8080 to the HA Pi: deferred (2026-10-24, D-156); ask again only if the owner raises it. +- Image-build location: the Pi 3 runner or the Mac fallback; the owner decides after the current run's result (D-156). - State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built? -- Answered 2026-10-21: auth on everything, the key is the app itself, the landing page on the LAN only is OK (D-126). Answered 2026-10-20: Kvitt on the Pi is a self-contained compose project in `~/kvitt` with nothing installed elsewhere (D-125). Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124). Moot: `write:repository` for the registry secret (the tag push works with `write:package`). +- Answered 2026-10-24 (D-156, D-157): OQ-6 no confirm step on S-1 for an unusual server; L-10 `not_found` for the other user's device; `mem_limit` kept, the warning accepted; image builds stay on the Pi for now; the web client is first used from the laptop browser; small edits by a Haiku or Sonnet agent in the main checkout, committed on `main` by the orchestrator (D-157). Answered 2026-10-21: auth on everything, the key is the app itself, the landing page on the LAN only is OK (D-126). Answered 2026-10-20: Kvitt on the Pi is a self-contained compose project in `~/kvitt` with nothing installed elsewhere (D-125). Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124). Moot: `write:repository` for the registry secret (the tag push works with `write:package`). ## 5. Paused (waiting on something outside the repo) - Nothing. ## 6. Numbering -- Decisions: D-001 to D-155 used, except D-008, D-009 and D-150 (never used). Next free: **D-156**. -- Spec version: v0.32 written (D-144 to D-155). Next **v0.33**. -- 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.32 (no plan since needed a migration). +- Decisions: D-001 to D-157 used, except D-008, D-009 and D-150 (never used). Next free: **D-158**. +- Spec version: v0.33 written (D-156, D-157). Next **v0.34**. +- 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.33 (no plan since needed a migration). - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index c54c67c..6b106dc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -160,3 +160,5 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-153 | 2026-10-21 | W-18's route is `/app/settings/invite` (W0's `paths.settingsInvite`), not `/app/settings/invites` as §15.6 said; W5 also routes `/app/settings/invites` to the same page so the old address works. Sub-routes of W-15 and W-16: `/app/settings/categories/new`, `/app/settings/categories/:id`, `/app/settings/recurring/new`, `/app/settings/recurring/:id`. Landed `3ebe57d`. | W0's route table was fixed before W5 started and the singular matches the S-21 screen; the spec follows the code. | SPEC §15.6 W-15, W-16, W-18; `web/src/features/settings/index.ts` | | D-154 | 2026-10-21 | Partner first login and About on the web. The S-2 step after a `partner` login (AC-2.10) is an intro on W-14 Profile with "Fortsätt"; this browser remembers it in `localStorage` under `kvitt.profileStepDone`, holding the device id, so a later login on the same browser as a new device shows it again. W-19 About reads E-1 `/health`; when it does not answer (from outside the LAN, §15.2), it reads the `X-Kvitt-Server-Version` header of a fresh `GET /me` and shows the version without the schema (D-110's fallback). Landed `3ebe57d`. | AC-2.10 asks for the step once; the device id ties "once" to this login rather than to the browser forever. A fresh request gives a current header instead of a cached one. | SPEC AC-2.10, §15.2, §15.6 W-14, W-19; `web/src/features/settings/ProfilePage.tsx`, `AboutPage.tsx` | | D-155 | 2026-10-21 | A failed or declined update install is no longer silent. The `PackageInstaller` session's final status reaches `UpdateRepository` by session id (`InstallResultReceiver` hands it to the waiting `AndroidApkInstaller` call; `ApkInstaller.install` returns a `PendingInstall`). `STATUS_FAILURE_BLOCKED` gives `UpdateState.Failed(info, UpdateFailure.Blocked)`: "Kunde inte installera uppdateringen" (`update_install_failed`) with the security-settings text (`update_install_blocked`); `STATUS_FAILURE_ABORTED` (the user declined the system dialog) goes back to `Available` without an error; every other failure gives `Failed(info, UpdateFailure.Install(detail))` with the installer's own status message as the body. `UpdateState.Failed` gains `reason: UpdateFailure = UpdateFailure.Download` (`sealed interface UpdateFailure { Download; Blocked; Install(detail: String?) }`, `:core:model` `Sync.kt`), so existing fakes are unchanged. The cached APK is deleted once the installer answers, so "Försök igen" downloads again. Goldens `S-17-install-blocked`, `S-17-install-failed`, `s22_about_install_blocked` new, `update_banner` re-recorded. Landed `d686ee0`. | A refused or failed install left the banner as it was, so a tap on "Uppdatera" seemed to do nothing. Showing the reason tells the user which setting to change or what the installer said; a declined dialog is the user's choice, not an error. | SPEC AC-16.7, §14.2.4; `docs/ux/design-spec.md` §3.6 `UpdateBanner`, S-17; `docs/ux/strings.csv`; `android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt`, `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt`, `android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt` | +| D-156 | 2026-10-24 | Owner answers and phone observations of 2026-10-24. (1) OQ-6 (security review L-6): no confirmation step on S-1 for an invite link that prefills an unusual server; the default stands (S-1 shows the server row expanded, D-116). (2) Security review L-10: a member who names a device of the other user gets `not_found`, the same as an unknown id, not `forbidden` (default accepted); **to implement**: `revoke_device` in `server/crates/kvitt-server/src/api/devices.rs` answers `forbidden` today, and `contracts/openapi.yaml` lists 403 for E-16, so the change drops both (contract change, §14.8). (3) A host firewall rule limiting the Kvitt Pi's port 8080 to the Home Assistant Pi (`HA_HOST`): not now, deferred (open). (4) `mem_limit` in `infra/pi/compose.yaml` stays; the Pi kernel without the memory cgroup ignores it with a warning, accepted. (5) Images keep being built by the Gitea workflow on the Pi 3 runner for now; the owner waits for the result of the run in progress before deciding otherwise; `infra/build-images.sh` on the Mac stays the fallback. The runner Pi's disk reached 11 of 15 GB during builds; the owner runs `docker builder prune -af` between builds. (6) Verified on the phones: Samsung Auto Blocker was the cause of the silent update failure (turning it off let the update install; the owner turned it back on), so the in-app install needs it off each time, and the D-155 blocked text names it; the editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) confirmed on the device; both phones run release 140. (7) The first backup on the Kvitt Pi ran and a snapshot pair is in the Drive folder (AC-18.1, verified by the owner). (8) The web client is first used from a browser on the owner's laptop; the phones keep the app. | The owner answered the open questions of HANDOFF §4 and SPEC §12 and reported what the phones and the Pi showed; L-10's leak is minor between two trusted users, but `not_found` hides whose a device is at no cost. Auto Blocker is a Samsung setting the app cannot change, so the spec and the owner's checklist name it. | SPEC AC-3.2, §8.4, E-16 (§14.3.4), AC-16.3, AC-18.1, §10.2, §10.3, §11.4, §12 OQ-6, §15.2; `infra/README.md` "Pi: what the owner does"; `docs/HANDOFF.md` | +| D-157 | 2026-10-24 | Small edits are made by a Haiku or Sonnet agent directly in the main checkout, not in a new worktree, and the orchestrator commits them on `main`. Owner's words: "be smart about it: Spin up smaller agents (like haiku or sonnet) for these super small things, and do not do it in a new worktree, instead simply do it directly here." Real work stays with Opus agents in their own worktrees. Refines the working rules that every change goes through an Opus agent in a worktree and that Haiku or Sonnet are only for owner-requested read-only scans. | A worktree, a full brief and an Opus agent cost far more than a one-line fix is worth. | `CLAUDE.md` › Orchestration (edited separately) | diff --git a/infra/README.md b/infra/README.md index c2fb077..24f6c0c 100644 --- a/infra/README.md +++ b/infra/README.md @@ -364,6 +364,10 @@ folders `data/`, `backups/`, `import/`. Nothing outside that folder is installed the Kvitt Pi's LAN IPv4 address (static DHCP lease; ASUS router web UI, LAN, DHCP Server, client list), also NPM's Forward Hostname / IP (step 4). +Between two workflow builds, run `docker builder prune -af` on the runner Pi: its 15 GB disk reached 11 GB during +builds (SPEC §10.3, D-156). On the Samsung phones, turn Auto Blocker off before tapping "Uppdatera" for an in-app +update and back on afterwards; with it on, the install is refused (SPEC AC-16.3, D-156). + ### 1. Prerequisites (already on the Pi) * Docker Engine with the compose plugin, usable by `jacobhagstedt` without sudo: `docker compose version` prints a @@ -733,6 +737,11 @@ or a leaked key (rotate it, step 4a). Encrypt certificate; the router's existing external 443 forward to the Home Assistant Pi (NPM) serves it, and there is no 8443 rule (D-124). * App gate (D-126, 2026-10-21): outside the home LAN every request needs the key built into the app (step 4a). +* `mem_limit` (2026-10-24, D-156): the lines stay in `compose.yaml`; the Pi kernel without the memory cgroup ignores + them with a warning, accepted. +* Firewall rule limiting port 8080 to `HA_HOST` (2026-10-24, D-156): not now (deferred). +* Image builds (2026-10-24, D-156): stay on the Pi runner for now; `infra/build-images.sh` on the Mac remains the + fallback. ### Troubleshooting