From ae73f58079698d9af4745ff78c08e362a66797ff Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 24 Oct 2026 13:14:00 +0200 Subject: [PATCH] SPEC v0.37: image builds on the Mac, v1.0.3 deployed, web client verified (D-211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra/build-images.sh on the Mac with Podman is the normal image build, the Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag rule, registry port 80 from the Mac (the Pi's .env keeps gitea.lan:3001), Podman insecure-registry files, the script fixes f15fa23 and 8fa9198. v1.0.3 on the Kvitt Pi; the owner verified the web client from the laptop browser and S-11's Insights (SPEC §10.3, §10.10, §11.2 to §11.4, §15; infra/README.md; HANDOFF). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- SPEC.md | 31 +++++++++++---------- docs/HANDOFF.md | 32 +++++++++------------ docs/decisions.md | 1 + infra/README.md | 71 ++++++++++++++++++++++++++++++++++------------- 4 files changed, 83 insertions(+), 52 deletions(-) diff --git a/SPEC.md b/SPEC.md index 27716c6..3589e8b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.36** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**. +Version **0.37** (2026-10-24). Schema version **4**. 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. @@ -1029,9 +1029,9 @@ 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/`). -* 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`). "Determine Version" also sets `VERSION_CODE` to `git rev-list --count HEAD` (the full history is fetched, `fetch-depth: 0`), and the server build passes `--build-arg KVITT_VERSION_CODE=$VERSION_CODE`, the web client's version code (§15.3, v0.34). 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. +* The Gitea Actions workflow (D-104; the fallback since D-211, see the Mac build below): `.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`). "Determine Version" also sets `VERSION_CODE` to `git rev-list --count HEAD` (the full history is fetched, `fetch-depth: 0`), and the server build passes `--build-arg KVITT_VERSION_CODE=$VERSION_CODE`, the web client's version code (§15.3, v0.34). 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, the normal path since D-211 (the fallback under D-104 and D-156): 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-211, revises D-156 point 5): on the owner's Mac with Podman, `infra/build-images.sh v1.0.N`; the Gitea workflow on the Pi 3 runner stays the fallback (about three hours per run and disk pressure on its 15 GB card; the owner cancels runs it queues). Tag rule: `git fetch --tags`, then the next `v1.0.N` after the highest `v1.0.*` tag on `origin`; the images carry that tag and `latest`, and the same tag is pushed as a git tag on the built commit (`git tag v1.0.N && git push origin v1.0.N`), so the workflow's own numbering continues after it (`v1.0.3` = `8fa9198`, the first Mac build). Registry from the Mac: `gitea.lan` on port 80, plain HTTP; `infra/secrets/registry.env` (git-ignored) holds `KVITT_REGISTRY=gitea.lan/jacobh2`, `KVITT_REGISTRY_USER` and a `write:package` token as `KVITT_REGISTRY_TOKEN`. The Pi's own `.env` keeps `KVITT_REGISTRY=gitea.lan:3001/jacobh2`, the port it reaches the same registry on locally; both name the same images. Podman marks the registry insecure in two places, `~/.config/containers/registries.conf.d/kvitt.conf` on the Mac and `/etc/containers/registries.conf.d/kvitt.conf` inside the Podman machine, each `[[registry]]` `location = "gitea.lan"` `insecure = true` (owner's allowance 2026-10-24 for these files outside the repository). Two script fixes made the Mac path work: the server build gets `--build-arg KVITT_VERSION_CODE=` as the workflow passes it (`f15fa23`), and the engine test captures `docker version` before matching it (`8fa9198`: under `pipefail`, `grep -q` closed the pipe early, `docker version` died of SIGPIPE and the script chose docker over Podman). The script prints no image digests, so the digest comparison of §10.10 applies to workflow builds. If the fallback runs, the owner still runs `docker builder prune -af` on the runner Pi between builds (its disk reached 11 of 15 GB); that can drop the BuildKit cache, so the next server build there 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; the stage has no `.git`, so `ARG KVITT_VERSION_CODE=1` is passed to `npm run build` as the environment variable `KVITT_VERSION_CODE`, the web's version code (§15.3; default 1, the workflow passes the commit count, v0.34); 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`. @@ -1351,7 +1351,7 @@ Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the o * Where: every command on the Pi in this section is run by the owner in `~/kvitt` (`/home/jacobhagstedt/kvitt`) with plain `docker compose` (D-125, revises D-103's scripts); the owner's operations table is in `infra/README.md` "Pi: what the owner does", Operations. Agents prepare and check the project on the Mac and never connect to the Pi (D-100). * Status and logs: `docker compose ps`; `docker compose logs --tail 100 `; NPM's own log files are inside NPM on the Home Assistant Pi (§10.5.2). * Disk: `df -h ~` and `du -sh ~/kvitt/*`. SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), older ones removed by hand. -* Deploy (upgrade), after the Gitea workflow pushed a `v1.0.N` tag (or `infra/build-images.sh` printed one): `docker compose exec backup /backup.sh` first, continuing only after `backup: ok`, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then set `KVITT_TAG` in `.env` (`sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env`, noting the previous tag), `docker compose pull && docker compose up -d`, and check `docker compose ps` and `curl -s localhost:8080/health`. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop. +* Deploy (upgrade), after `infra/build-images.sh v1.0.N` on the Mac pushed the images (D-211; or the Gitea workflow, the fallback, pushed a `v1.0.N` tag): `docker compose exec backup /backup.sh` first, continuing only after `backup: ok`, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then set `KVITT_TAG` in `.env` (`sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env`, noting the previous tag), `docker compose pull && docker compose up -d`, and check `docker compose ps` and `curl -s localhost:8080/health`. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop. * Rollback: set the previous `KVITT_TAG` in `.env` and `docker compose pull && docker compose up -d` (no `previous-tag` file and no `--skip-backup` since D-125). Migrations are forward-only, so if a migration had partially applied, restore the pre-deploy backup (§10.9, `infra/RESTORE.md`); sqlx runs each migration in a transaction, so this should not happen. * Bootstrap and invites: `docker compose exec server kvitt-server bootstrap` (first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invite `docker compose exec server kvitt-server invite-partner`; an extra device `docker compose exec server kvitt-server pair-device --user ` (§10.4). * Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing. @@ -1469,17 +1469,17 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps of the M2c-pi compose project (D-100, D-125); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 | | M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 | | W0 | Web foundation (D-131, D-134) | Landed `2ae65a5` (2026-10-21). `web/`: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` types in `web/src/api/schema.d.ts` and `openapi-fetch`, Vitest and Testing Library, ESLint and Prettier; design tokens from `docs/ux/design-spec.md` §2 as CSS custom properties; layout shell (sidebar, bottom bar under 720 px), router with every route of §15.3, keyboard shortcuts and W-20; login (W-1), token store and 401 handling; API client and household snapshot (§15.5); i18n (sv, en); formatting (§9.8); shared components; the money functions of §14.5.2 passing their vectors; `npm run check` | Web gate | none | SPEC v0.28 | -| W1 | Web: home and accounts | Landed `934eee7` (2026-10-21, D-144, D-145, SPEC v0.32). W-2, W-3, W-4 in `web/src/features/{home,accounts}/` | Web gate | Look at Home and an account in a browser against the development stack (yes/no) | W0 | -| W2 | Web: expenses, search and export | Landed `a65ab71` (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in `web/src/components/editor/`). W-5 with the §9.11 suggestion (`suggest_category.json`, in the vector test since `83f5293`), W-6 with the timeline, W-12 with the CSV export, in `web/src/features/{expenses,search,export}/` | Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no) | W0 | -| W3 | Web: settle up | Landed `f9a9bfb` (2026-10-21, D-144, D-149, SPEC v0.32). W-7, W-8 in `web/src/features/settle/` | Web gate | Record and delete a settlement in a browser (yes/no) | W0 | -| W4 | Web: analytics and insights | Landed `1aecf51` (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); `resolveRange` and `spendSeries` passing their vectors (`features/analytics/domain/analytics.vectors.test.ts`); 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 | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). 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 | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). 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 | +| W1 | Web: home and accounts | Landed `934eee7` (2026-10-21, D-144, D-145, SPEC v0.32). W-2, W-3, W-4 in `web/src/features/{home,accounts}/` | Web gate | Look at Home and an account in a browser against the development stack (yes/no); done on the Pi 2026-10-24 (D-211) | W0 | +| W2 | Web: expenses, search and export | Landed `a65ab71` (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in `web/src/components/editor/`). W-5 with the §9.11 suggestion (`suggest_category.json`, in the vector test since `83f5293`), W-6 with the timeline, W-12 with the CSV export, in `web/src/features/{expenses,search,export}/` | Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no); done 2026-10-24 (D-211) | W0 | +| W3 | Web: settle up | Landed `f9a9bfb` (2026-10-21, D-144, D-149, SPEC v0.32). W-7, W-8 in `web/src/features/settle/` | Web gate | Record and delete a settlement in a browser (yes/no); done 2026-10-24 (D-211) | W0 | +| W4 | Web: analytics and insights | Landed `1aecf51` (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); `resolveRange` and `spendSeries` passing their vectors (`features/analytics/domain/analytics.vectors.test.ts`); in `web/src/features/analytics/` | Web gate | Look at the analytics pages in a browser (yes/no); done 2026-10-24, Insights included (D-211) | W0; W-11 needs W7 | +| W5 | Web: settings | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). 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); done 2026-10-24 (D-211) | W0 | +| W6 | Android: Insights | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). 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); done 2026-10-24 against `v1.0.3` (D-211) | W7 landed, its image deployed | | K0 | Card purchases spike: local test app (D-176) | The test app landed `2ce317f`: `docs/spikes/k0/app.py` and its `README.md`, one command (`python3 docs/spikes/k0/app.py`, the standard library only, the RS256 JWT signed with `openssl`), a page on `http://localhost:8765/` that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored `docs/spikes/k0/.local/`. Done 2026-10-24: run with the owner on his card; report `docs/spikes/K0-enable-banking.md` (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps | The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; `git status` shows nothing from `.local/`; no gate (not part of any gate) | Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in `.local/`, the owner's card linked with BankID, the test app run (PLAN-K0 §8) | SPEC v0.35 | | K1 | Card purchases: server (D-158 to D-175) | Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) | | K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` | Android gate | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8) | SPEC v0.36; may start then on the spec and fakes; lands after K1 | | K3 | Card purchases: web (D-174) | W-21 to W-23 in `web/src/features/cards/`, the sidebar entry, the S-7-style prefill in W-5 and the card line in W-6, the committed card artwork mapped (D-180), `matchCardPurchase` in TypeScript passing its vectors | Web gate | Connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) | -| 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 | +| 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); deployed 2026-10-24 as `v1.0.3` (D-211) | SPEC v0.28 | ### 11.3 Notes per milestone @@ -1509,7 +1509,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **Infra overhaul** (landed `2fac1ec` to `e70fe0d`, no decision): one Gradle lock helper `tools/gradle-lock.sh` for `tools/gradle-serial.sh`, `tools/clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs the Android tests through `tools/gradle-serial.sh` with JDK 25 on `PATH`, `JAVA_HOME` untouched; `install.sh` keeps the deployed `KVITT_TAG` (moot since D-125: no `install.sh`); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3; reversed by D-120 point 7); `RCLONE_REMOTE` dropped from the development env (§10 intro). **Infra security** (landed `7fb86f5`, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10; dropped by D-125 for `docker image inspect`), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). D-120 carries the log redaction and the headers over to NPM's Custom Nginx Configuration (§10.5.2) and removes the Caddy container, the digest of its image and the cert-sync key. * **Overhaul outcomes** (2026-10-18). Server: landed through `b985ce9` (see the server overhaul above). Infra: landed through `e70fe0d` (see the infra overhaul above). **Android overhaul** (landed `e4e6e5d` to `c582363`, D-118, no prompt file, orchestrator brief): one screenshot and test harness in `:core:design`'s test fixtures and the convention plugin `kvitt.android.screenshots` (§6.2); the shared failure texts, limits and `savedSplitLabel` in `:core:design` (§6.2); dead code removed (`KvittMotion` down to the seesaw spring, `KvittIcon.Description`, unused dimens and shapes, catalogue and fake samples); the catalog entries `roborazzi-junit-rule`, `roborazzi-compose`, `androidx-hilt-navigation-compose` and `androidx-lifecycle-viewmodel-compose` removed; one `CopyCategoryKeywords` task in `build-logic` shared by `:data` and `:core:fakes` (D-055 holds); one `configureAndroidModule` for the application and library plugins; S-7's state types in `ExpenseEditorState.kt`. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed to `origin` at `c582363`. * **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. -* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups". +* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups". W0b landed `5305f37` (v0.34). All of W0 to W7 are landed and verified: `v1.0.3` (built on the Mac from `8fa9198`, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4). * **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records in SPEC v0.37. Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase. @@ -1517,7 +1517,7 @@ 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-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/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. Observed 2026-10-24 (D-211), the web client's first use (FR-21) and W6: image `v1.0.3` on the Kvitt Pi, `server` and `backup` healthy; `https://kvitt.suorra.duckdns.org/app/` paired from the laptop browser with a device invite; the owner reports "All seems to work" for Home and an account, add, edit and delete of an expense, the CSV export, recording and deleting a payment, analytics with a category drill-down and Insights, every settings page, revoking the browser from the phone, and the Android Insights section on S-11 against this server. * `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), fixed in `07c36d2` (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. --- @@ -3449,6 +3449,8 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `web/` and served by `kvitt-server` under `/app/`. It is a second way into the same household: the same server, the same rules, the same data, read and written through the same API (§7, §14.3, §14.4). Nothing in the phone app changes because of it, except the Insights section of W6 (§15.8). This section is binding for the web plans W0 to W5 and for the web parts of W7; FR-21 holds the acceptance criteria. +**Status (2026-10-24, D-211).** W0 to W7 and W0b are landed; the web client is live at `https://kvitt.suorra.duckdns.org/app/` in image `v1.0.3`, paired from the owner's laptop browser and checked by the owner across its pages (§11.4). + ### 15.1 Scope (D-129) * **Parity.** Everything the Android app does, except what only makes sense on a phone: the phone pairing flow (S-1's server field, `kvitt://` links, S-3 as a separate screen, S-18 as a separate screen), push notifications (FR-13), offline use and the sync screen (FR-15, S-16), the in-app update (FR-16, S-17). In: Home balances, account detail, account editor, expense add, edit and delete, expense detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with the D-128 line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about; and since v0.35 the card purchases of FR-22 (D-174, K3: W-21 to W-23), with BankID by QR in the desktop browser. @@ -3595,3 +3597,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0 | 0.34 | 2026-10-24 | L-10 implemented (`07c36d2`, D-156) and the web shared follow-ups (W0b, `5305f37`), no new decision. L-10: a member naming the other user's device gets E-16's `not_found`, the same body as an unknown id; `contracts/openapi.yaml` drops E-16's 403 and `web/src/api/schema.d.ts` is regenerated (contract change, §14.8); the "to implement" notes of AC-3.2, E-16 (§14.3.4) and §11.4 removed; `Cargo.lock` bumps `ctutils` 0.4.3 → 0.4.4 (RUSTSEC-2026-0385, published 2026-10-23, which failed `cargo audit` on `main`). Server tests 300; contracts-check 136. W0b: shared web pieces once in `components`, `lib`, `api` and `app`, no feature imports another (`catalog.test.ts` also checks `web_` keys); amount fields per page language; Recharts in its own chunk (vendor 516 → 132 kB); Vitest cache off; Testing Library's `findBy`/`waitFor` limit 5 s in `web/src/test/setup.ts` (load on the Mac during parallel agents); the version code from the commit count, else `KVITT_VERSION_CODE`, else 1 (§15.3). Web gate 609 tests. Image build: `server/Dockerfile`'s Node stage takes `ARG KVITT_VERSION_CODE=1` into `npm run build`, and the Gitea workflow passes `--build-arg KVITT_VERSION_CODE=` (§10.3). | | 0.35 | 2026-10-24 | Card purchases through Enable Banking (owner's request and answers of 2026-10-24, D-158 to D-176) and plans K0 to K3. D-158: card purchases in scope, §2's card and bank non-goal struck through with a pointer, new US-18, US-19, FR-22 (AC-22.1 to AC-22.15), §9.12 (CP-1 to CP-14). D-159: Enable Banking in restricted production for the SJ Prio Mastercard (SEB Kort), G-7 allows it, PSD2 limits stated as facts, ToS as the owner's licence step. D-160: server-only credentials, one adapter `src/cards/enable_banking/`, provider registry, card catalogue E-36. D-161: only the card holder sees their purchases (owner's answer): own endpoints and cursor, not in `GET /sync`, audit, push or analytics, `not_found` for the other user, no card content in logs, session id protected by separation from the private key (§8.1, §8.4, §8.7, §10.9). D-162: connect flow with single-use attempts (E-37, E-38), the callback E-39 and its bilingual page, `kvitt://cards/connected`, polling. D-163: `/cards/callback` through the app gate and never logged (§10.5.2 block). D-164: history 7/30/90/180 days, default 30. D-165: background fetches at 06:45, 11:45, 16:45, 21:45, at most 4 per 24 hours; user-present "Uppdatera" with Enable Banking's PSU headers, once per 60 s; incremental fetch with a 10-day overlap; `card_bank_unavailable`. D-166: consent up to 180 days, notice 14 days before, renewal with BankID; `card_consent_expired`. D-167: migration 0004, server schema 4 (`card_sequence`, `card_connections`, `card_auth_attempts`, `card_purchases`, §14.1.3); disconnect rules (E-43). D-168: transaction mapping, pending→booked de-duplication, foreign currency, credits listed only under "Alla". D-169: "Skapa utgift" prefill, links (E-44), offline-capable link changes, unlink on expense delete (§14.4.4), `expense_already_linked`. D-170: suggested matches (same amount, ±3 days), `matchCardPurchase` with vectors `match_card_purchase.json` (§14.5.8). D-171: no bulk add, no card push (OQ-7, OQ-8). D-172: four bottom destinations, Översikt, Kortköp, Statistik, Inställningar (revises D-048); "Anslut kort" in Settings. D-173: `:feature:cards`, S-23 to S-27, Room version 2, `CardRepository` (§14.2.4), the owner's SJ Prio artwork with a drawn fallback. D-174: web W-21 to W-23 (K3). D-175: `KVITT_EB_*` configuration, `secrets/cards/`, two optional `.env` keys; `cards_unavailable`. D-176: plans K0 (the local test app of `2ce317f`, owner's refinement) to K3; K0's `TODO(spec)` answers go into v0.36 before K1. Endpoints E-36 to E-44; strings for the card screens and the callback page in §13.3; ownership rows K0 to K3 (§14.7); contract files 0004 and the new vector file (§14.8). | | 0.36 | 2026-10-24 | K0's results (the spike run on the owner's SJ Prio Mastercard on 2026-10-24 with `docs/spikes/k0/app.py`, report `docs/spikes/K0-enable-banking.md`), D-177 to D-180; no contract file changes, server schema 4 and Room 2 as specified in v0.35. D-177: K0's outcome; every K0 `TODO(spec)` of §9.12 answered: ASPSP `SJ Prio Mastercard`, `SE` (CP-1); one `CARD` account per session (CP-2); at least 150 days of history right after BankID, so 7/30/90/180 stay (CP-3, §9.6); a background fetch without PSU headers accepted (CP-4); `required_psu_headers` [`psu-ip-address`], compared case-insensitively (CP-5); `maximum_consent_validity` 180 days, `valid_until` 2027-04-22 (CP-6); foreign purchases arrive in SEK (CP-7); credits were card-bill payments (CP-8); and the owner's assumptions of 2026-10-24 for what was not measured: the partner's card links to the owner's application (CP-13, the D-175 fallback stays), a pending transaction keeps its `entry_reference` when booked (CP-8, the amount-and-date rule becomes the secondary rule), the session is reused on later days without BankID (CP-6), all three verified by K1's owner steps. D-178: the card returns at most 100 transactions per response and never a `continuation_key`, so history is fetched in windows of at most 30 days, newest first, a window at 100 rows split in half down to one day, de-duplicated across windows by `provider_ref`; `strategy=longest` dropped; `WRONG_TRANSACTIONS_PERIOD` ends the walk back; only a complete fetch deletes vanished pending purchases (CP-7, CP-8); `card_fetch` counts requests, splits, truncated windows, `booked_by_ref` and `booked_by_match` (§8.7). D-179: transaction mapping from K0's field survey: merchant = `remittance_information`, else `creditor.name`, else "Kortköp"; `purchase_date` = `booking_date`, else `value_date`, else `transaction_date`; `bank_transaction_code` unused, so a debit `FEE` is a purchase to handle; the holder's name, the masked card number (`CPAN`) and the merchant's address are never stored, logged or shown (CP-7). D-180: the owner's SJ Prio Mastercard artwork cropped to the card (1313 × 829) and committed for Android and the web, mapped from the first commit of K2 and K3, the goldens use it, the owner's artwork steps removed (revises D-173, D-174). The K plans' proposed spec text goes into v0.37 (§11.1, §11.3). | +| 0.37 | 2026-10-24 | D-211: image builds on the Mac with Podman (`infra/build-images.sh v1.0.N`) are the normal path, the Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag rule (the next `v1.0.N` after the highest tag on `origin`, pushed as a git tag too, `v1.0.3` = `8fa9198`); the registry from the Mac is `gitea.lan` on port 80, the Pi's `.env` keeps `gitea.lan:3001`; Podman's insecure-registry files on the Mac and in the Podman machine; the script fixes `f15fa23` and `8fa9198` (§10.3, §10.10). `v1.0.3` deployed on the Kvitt Pi and the web client and S-11's Insights verified by the owner (§11.2 W1 to W7, §11.3, §11.4, §15). No contract file changes. | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index deb3fca..c71e29c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,12 +3,12 @@ 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-24. `main` at `8fa9198` (`infra/build-images.sh` Podman detection) and `f15fa23` (`infra/build-images.sh` passes `KVITT_VERSION_CODE`), after `4339d01` (the owner's SJ Prio card artwork), `b5114ad` (SPEC v0.35, card purchases), `2ce317f` (the K0 card test app), `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups, pushed to `origin` 2026-10-24); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones (verified 2026-10-24). Next: the Pi image build of the pushed `main` with the complete web app (the Gitea workflow on the Pi runner; this pass's `server/Dockerfile` and workflow change triggers another run when pushed), 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). SPEC v0.34 (`5d948f9`) recorded L-10 as implemented and W0b and passes the web's version code into the image build (`ARG KVITT_VERSION_CODE` in `server/Dockerfile`, `--build-arg KVITT_VERSION_CODE=` in the workflow). Gates on `main`: server 300 tests, web 609 tests, contracts-check 136. +- State as of 2026-10-24. `main` at `8b620c8` (the K0 report, SPEC v0.36), after `8fa9198` (`infra/build-images.sh` Podman detection), `f15fa23` (`infra/build-images.sh` passes `KVITT_VERSION_CODE`), `4339d01` (the owner's SJ Prio card artwork), `b5114ad` (SPEC v0.35, card purchases), `2ce317f` (the K0 card test app), `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). This pass: SPEC v0.37, D-211. **Deployed and verified 2026-10-24** (owner, D-211): image `v1.0.3` (git tag `v1.0.3` = `8fa9198`, built on the Mac) runs on the Kvitt Pi, `server` and `backup` healthy; the web client is live at `https://kvitt.suorra.duckdns.org/app/`, paired from the laptop browser with a device invite, and the owner reports "All seems to work" for Home and an account, add, edit and delete an expense, CSV export, record and delete a payment, analytics with category drill-down and Insights, the settings pages, revoking the browser from the phone, and the Android Insights section on S-11 against the new server. W0 to W7 are landed and verified (SPEC §11.2, §15). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones. Gates on `main`: server 300 tests, web 609 tests, contracts-check 136. - **Card purchases programme** (owner request and answers 2026-10-24, SPEC v0.35, D-158 to D-176, FR-22, §9.12): each user connects their own SJ Prio Mastercard (SEB Kort) through Enable Banking's restricted production mode with BankID; the server alone holds the key and sessions, fetches purchases up to 4 times a day in the background plus "Uppdatera", and only the card holder sees them (not in `GET /sync`, audit, push or analytics); a fourth bottom destination "Kortköp" (Översikt, Kortköp, Statistik, Inställningar, revising D-048), "Anslut kort" in Settings with the SJ Prio artwork, "Skapa utgift" prefilled into S-7, one-tap links to matching expenses (same amount, ±3 days), "Från kortköp" on account detail; server schema 4 (migration 0004), Room 2, endpoints E-36 to E-44, screens S-23 to S-27, web W-21 to W-23. Plans `PLAN-K0.md` to `PLAN-K3.md`, prompts `prompts/K0.md` to `prompts/K3.md`, written by `agent-a3b7abf183f883261`, landed `b5114ad`. **K0 done** (2026-10-24): the test app (`2ce317f`) ran with the owner on his card; report [`docs/spikes/K0-enable-banking.md`](spikes/K0-enable-banking.md); its answers are SPEC v0.36 (D-177 to D-180). Main findings: the card returns at most 100 transactions per response and never a continuation key, so history is fetched in windows of at most 30 days, split in half at 100 rows (D-178); merchant text from `remittance_information`, the date `booking_date` else `value_date`, everything in SEK, `entry_reference` on pending rows too (D-179); consent 180 days; background fetch accepted; at least 150 days of history right after BankID. **Owner's assumptions** (2026-10-24, "let's just assume it all works now given your test", D-177), verified by K1's owner steps (PLAN-K1 §8 steps 5 to 7): the partner's card links to the owner's application (fallback: her own application, D-175), a pending purchase keeps its `entry_reference` when booked (fallback: CP-8's amount-and-date rule), the session is reused the next day without BankID. The Enable Banking application exists (restricted production, redirect URL `https://kvitt.suorra.duckdns.org/cards/callback`, the owner's card linked); its key file lives only in the main checkout's git-ignored `docs/spikes/k0/.local/.pem` and reaches the Pi through K1's owner steps (`~/kvitt/secrets/cards/enable-banking.pem`, D-175), never downloaded again. The artwork is committed for Android and the web, cropped to the card (D-180). **Next: K1** (server, contract) with **K2** (Android) and **K3** (web) starting in parallel on the spec and fakes; K1 lands first, K2 after it, K3 after K2; their proposed spec text goes into v0.37. - **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. +- **Images** (D-211, revises D-156 point 5): built on the Mac with Podman, `infra/build-images.sh v1.0.N`, the next `v1.0.N` after the highest tag on `origin` (`git fetch --tags`), pushed as a git tag too; the Gitea workflow on the Pi 3 runner is the fallback (about three hours per run, disk pressure; the owner cancels the runs it queues). `gitea.lan/jacobh2/kvitt-server` and `kvitt-backup`, tags up to `v1.0.3` (the first Mac build, `8fa9198`, the tag the Pi runs) plus `latest`; the packages are public, so the Pi pulls without `docker login`. Registry from the Mac: `gitea.lan` on port 80 (`infra/secrets/registry.env`: `KVITT_REGISTRY=gitea.lan/jacobh2`, user, `write:package` token); the Pi's `.env` keeps `gitea.lan:3001/jacobh2` (same registry). Podman's insecure-registry files: `~/.config/containers/registries.conf.d/kvitt.conf` on the Mac and `/etc/containers/registries.conf.d/kvitt.conf` in the Podman machine (owner's allowance 2026-10-24). Workflow history: run 69 (2026-10-19) built `v1.0.0`, `kvitt-server` 2 h 42 min on the Pi 3. SPEC §10.3, `infra/README.md` "Image build on the Mac". - **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/`; its loose ends done by W0b (`5305f37`, §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`). @@ -67,8 +67,7 @@ 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 answered 2026-10-24: `not_found` for the other user's device (D-156), fixed in `07c36d2`. 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 (K0 close-out: SPEC v0.36, `docs/decisions.md` D-177 to D-180, `docs/spikes/K0-enable-banking.md`, the cropped artwork in `android/feature/cards/` and `web/src/features/cards/assets/`, `PLAN-K0.md` to `PLAN-K3.md`, `prompts/README.md`, this file). - - The Pi image build of the pushed `main`, then the owner's deploy and browser checks (§3 steps 3 and 4). + - This docs pass (`agent-docs-v35`): SPEC v0.37, `docs/decisions.md` D-211 (Mac image builds, `v1.0.3` deployed, the web client verified), `infra/README.md`, this file. - 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 `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). @@ -79,12 +78,11 @@ 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 the K0 close-out pass (orchestrator): SPEC v0.36, D-177 to D-180, the K0 report, the cropped artwork (two PNGs, referenced by no build yet), the K plans, `prompts/README.md`, this file; gate `python3 tools/contracts-check.py` (no contract file changed). - K1, K2, K3 (Opus agents, once SPEC v0.36 is on `main`; decisions from D-181 in disjoint ranges, spec version v0.37 for their proposed text): K1 lands first, K2 after K1, K3 after K2. After deploy, K1's owner steps 5 to 7 verify K0's three assumptions (D-177). - K0 test app session in `docs/spikes/k0/.local/state.json` (owner): keep it or press "9 Koppla från" in the test app (PLAN-K0 §6 step 7); K1 does not use it. The key file stays in `.local/` either way. - Card design-spec sections S-23 to S-27 for `docs/ux/design-spec.md` (docs agent, from K2's report). - Closed by `f15fa23` and `8fa9198`: `infra/build-images.sh` passes `--build-arg KVITT_VERSION_CODE` to the server build as the workflow does (v0.34), and detects Podman without a pipe. -- Pi image with the complete web app (built from the push of `5305f37`, or of a later push of `main`): 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. +- Closed 2026-10-24 (D-211): image builds moved to the Mac (`infra/build-images.sh v1.0.N`, the workflow the fallback); `v1.0.3` deployed on the Kvitt Pi; the web client checked from the laptop browser and S-11's Insights on the phone. - 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: `5305f37` (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). @@ -92,16 +90,16 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - 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. - 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). - Closed by `07c36d2`: security review L-10 (E-16 `not_found` for the other user's device, 403 dropped from `contracts/openapi.yaml`; also `ctutils` 0.4.4 for RUSTSEC-2026-0385). - 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). +- Unverified risk (the fallback workflow only): 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`. - Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/.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): all landed: W0 (`2ae65a5`), W1 (`934eee7`), W2 (`a65ab71`), W3 (`f9a9bfb`), W4 (`1aecf51`), W5 (`3ebe57d`), W6 (`1c9856b`), W7 (`50a0d67`); D-144 to D-154 recorded in v0.32; W0b follow-ups `5305f37` (v0.34). + - W0 to W7 and W0b verified on `v1.0.3` 2026-10-24 (D-211). - 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"). ### Web follow-ups @@ -110,29 +108,25 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - The image's version code: `ARG KVITT_VERSION_CODE` in `server/Dockerfile` and the workflow's build argument (`5d948f9`); `infra/build-images.sh` since `f15fa23`. ## 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. 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`. +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); `v1.0.3` deployed and the web client checked from the laptop browser, S-11's Insights on the phone (the old steps 3 and 4, D-211). +Standing note for every in-app update on the Samsung phones: turn Auto Blocker off before tapping "Uppdatera", back on afterwards (D-156). Images are built on the Mac (D-211); only if the fallback workflow runs: `docker builder prune -af` on the runner Pi between two of its builds. 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 of the pushed `main` 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. - -5. Card purchases: K0's owner steps are done (2026-10-24: Enable Banking account and Terms of Service, the application, the key in `docs/spikes/k0/.local/`, the owner's card linked, the test app run); the artwork is committed (D-180). The partner's card and the next-day check moved to K1's owner steps (PLAN-K1 §8 steps 5 to 7, after K1 and a client with the card screens are deployed); K1's steps 2 to 4 (key to the Pi, `.env`, NPM block, deploy) come first. +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). The image with the web client is deployed (`v1.0.3`, D-211); 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. Card purchases: K0's owner steps are done (2026-10-24: Enable Banking account and Terms of Service, the application, the key in `docs/spikes/k0/.local/`, the owner's card linked, the test app run); the artwork is committed (D-180). The partner's card and the next-day check moved to K1's owner steps (PLAN-K1 §8 steps 5 to 7, after K1 and a client with the card screens are deployed); K1's steps 2 to 4 (key to the Pi, `.env`, NPM block, deploy) come first. ## 4. Owner questions (asked, unanswered) - OQ-7 (SPEC §12): a notification for new card purchases? Default: none in this version (D-171). - OQ-8 (SPEC §12): turning several card purchases into expenses at once? Default: not in this version (D-171). - 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-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`). +- Answered 2026-10-24 (D-211): image builds move to the Mac (`infra/build-images.sh`), the Pi workflow is the fallback. 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-180 used, except D-008, D-009 and D-150 (never used); D-158 to D-176 are the card purchases (v0.35), D-177 to D-180 K0's results and the artwork (v0.36). Next free: **D-181**. -- Spec version: v0.36 written (K0's results, D-177 to D-180). Next **v0.37** (the proposed spec text of K1, K2 and K3, recorded by a docs agent as they land). +- Decisions: D-001 to D-180 and D-211 used, except D-008, D-009 and D-150 (never used); D-158 to D-176 are the card purchases (v0.35), D-177 to D-180 K0's results and the artwork (v0.36), D-211 the Mac image builds and the verified `v1.0.3` deploy (v0.37). **D-181 to D-210 are reserved for K1 to K3**; next free after them: **D-212**. +- Spec version: v0.37 written (D-211: Mac image builds, `v1.0.3` deployed, the web client verified). Next **v0.38**. The K plans, `prompts/README.md`, §2's K item and SPEC §11.1 and §11.3 still name v0.37 for the K1 to K3 text (orchestrator: give them v0.38 in the briefs or have the references renamed). - Server schema version **3** in code (migration 0003); **4** specified (migration 0004, §14.1.3) for K1. Room schema version **1** in code; **2** specified (§14.2.3) for K2. - New ids in v0.35: FR-22 (AC-22.1 to AC-22.15), US-18, US-19, CP-1 to CP-14 (§9.12), S-23 to S-27, W-21 to W-23, E-36 to E-44, OQ-7, OQ-8; plans K0 to K3. - Sync protocol **1**, push payload **1**. diff --git a/docs/decisions.md b/docs/decisions.md index a64e956..2133d51 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -185,3 +185,4 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-178 | 2026-10-24 | Windowed history fetch (revises D-165's `strategy=longest` retry and CP-7's paging). K0: every transactions response of the SJ Prio card holds at most 100 transactions and never a `continuation_key` (90 days: 100 rows covering only the newest 60 days; 180 days with `strategy=longest`: 100 rows, a mixed selection; 30 days: 52; 7 days: 12; five consecutive 30-day windows: 67, 85, 55, 49, 52, 308 distinct). Rule: a fetch's range is split into consecutive windows of at most 30 days, newest first, each asked with `date_from` and `date_to`; a window that returns 100 or more transactions is split in half (the newer half takes the odd day) and both halves are asked again, down to one day; a one-day window still at 100 is kept and counted as truncated; a `continuation_key`, if ever returned, is still followed; `strategy` is never sent; `WRONG_TRANSACTIONS_PERIOD` for a window skips it and every older one and the fetch succeeds; rows are kept as returned (they may be dated one or two days outside their window) and de-duplicated across windows by `provider_ref`, the newer window's copy winning; any other error ends the fetch with CP-14's error after applying the windows already fetched; only a complete fetch (every window answered, none truncated) deletes vanished pending purchases. The `card_fetch` log line gains the counts of requests, split and truncated windows, and `booked_by_ref` / `booked_by_match` (pending purchases booked under their own reference or taken over by CP-8's secondary rule). | 30 days stays under the cap with margin on this card (the busiest measured month was 85 rows) and costs 6 requests for the 180-day first fetch and one for an incremental fetch, so a background fetch stays one bank access; halving handles a busier month without a new constant; a cut response is otherwise silent, so the count is the only signal; deleting a pending purchase on an incomplete picture would lose a real purchase. | SPEC §8.7, §9.12 CP-3, CP-4, CP-7, CP-8, §11.2 K1; PLAN-K1 | | D-179 | 2026-10-24 | Transaction mapping from K0's field survey (100 transactions; revises D-168's merchant and date order). Merchant: the `remittance_information` lines joined with a space (K0: exactly one line on every row, the merchant text), else `creditor.name` (K0: never present), else "Kortköp". `purchase_date`: `booking_date`, else `value_date` (pending rows have no booking date), else `transaction_date` (K0: never present); a row with none is skipped; `booking_date` stored when booked. `provider_ref` stays `entry_reference` (K0: on every row, pending included, unique, stable); the fallback hash uses `purchase_date`. `bank_transaction_code` (K0: `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2, `FEE` 1) is not used: the direction alone decides, so a debit `FEE` (a card fee) or `PAYMENT` is a purchase to handle that the holder turns into an expense or ignores, and a credit is listed under "Alla" as before. Never stored, logged or sent to a client: `debtor.name` (the card holder), `debtor_account` (a masked card number, scheme `CPAN`), `creditor.postal_address` (the merchant's country and town), `transaction_id`, `bank_transaction_code`. No schema change: the fields Kvitt keeps are those of migration 0004. | The fields the card actually fills; the booking date is the bank's settled date, and a pending purchase shows its value date until it is booked; a fee is money the holder paid and may share or ignore like a purchase, while a code-based rule would hide fees and payments made with the card; the holder's name and card number add nothing for the holder and are personal data the server need not keep. | SPEC §9.12 CP-7, CP-8; PLAN-K1 | | D-180 | 2026-10-24 | Card artwork present from the start (revises D-173 and D-174, whose fallback stays for unknown image keys). The owner's SJ Prio Mastercard picture (committed `4339d01`, 1600 × 1094 with transparent margins) is cropped to the card's opaque bounding box (x 143 to 1455, y 134 to 962): 1313 × 829, aspect 1.584, transparent only at the rounded corners and in the semicircular notch on the right edge, as on the physical card; losslessly recompressed. The same file is committed at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` and `web/src/features/cards/assets/card-sj-prio-mastercard.png`. K2 and K3 map `sj_prio_mastercard` to it from their first commit and draw it at its own aspect ratio without clipping (the picture carries its own corners and notch); the goldens use it; the owner's artwork steps are removed from PLAN-K2, PLAN-K3 and HANDOFF. The picture shows the SJ and Mastercard logos; the owner cleared that use for this private, non-distributed app on 2026-10-24 ("Licences first"). | The file exists, so neither plan needs a follow-up to re-record goldens; cropping removes margins that would shrink the card inside `CardArt`'s box. | SPEC §9.12 CP-1, §11.2 K2, K3; PLAN-K2, PLAN-K3 | +| D-211 | 2026-10-24 | Image builds on the Mac (revises D-156 point 5 and D-104's "fallback" wording). `infra/build-images.sh v1.0.N` on the owner's Mac with Podman is the normal way to build and push `kvitt-server` and `kvitt-backup`; the Gitea Actions workflow on the Pi 3 runner stays as the fallback (about three hours per run, disk pressure; the owner cancels the runs it queues). Tag: after `git fetch --tags`, the next `v1.0.N` after the highest `v1.0.*` tag on `origin`, also pushed as a git tag on the built commit (`v1.0.3` = `8fa9198`). Registry from the Mac: `gitea.lan` on port 80, plain HTTP; `infra/secrets/registry.env` (git-ignored) holds `KVITT_REGISTRY=gitea.lan/jacobh2`, the user and a `write:package` token; the Pi's `.env` keeps `gitea.lan:3001/jacobh2`, the port it reaches locally; both name the same registry. Podman needs the registry marked insecure in `~/.config/containers/registries.conf.d/kvitt.conf` on the Mac and `/etc/containers/registries.conf.d/kvitt.conf` inside the Podman machine (`[[registry]] location = "gitea.lan" insecure = true`), owner's allowance 2026-10-24. Script fixes: the server build gets `KVITT_VERSION_CODE` as in the workflow (`f15fa23`); Podman detection without a pipe (`8fa9198`: `grep -q` under `pipefail` closed the pipe, `docker version` died of SIGPIPE and the script chose docker). Deployed and verified by the owner 2026-10-24: `v1.0.3` on the Kvitt Pi (`server` and `backup` healthy); the web client at `https://kvitt.suorra.duckdns.org/app/`, a laptop browser paired with a device invite; "All seems to work" for Home and an account, add, edit and delete an expense, CSV export, record and delete a payment, analytics with category drill-down and Insights, the settings pages, revoking the browser from the phone, and the Android Insights section on S-11 against the new server. This answers the open question "image-build location". | A cold arm64 server build took 48 s on the M4 (2026-10-18, before the web stage) against 2 h 42 min on the 1 GB Pi 3 (run 69), whose disk fills during builds; the workflow is kept because it needs no Mac. One tag sequence for both paths keeps `KVITT_TAG` monotonic whichever path built it. | SPEC §10.3, §10.10, §11.2 W1 to W7, §11.3, §11.4, §15; `infra/README.md` (Layout, How it fits together, the image-build sections, "Pi: what the owner does" steps 2 and 7, Owner decisions); `docs/HANDOFF.md` | diff --git a/infra/README.md b/infra/README.md index 24f6c0c..30c01ba 100644 --- a/infra/README.md +++ b/infra/README.md @@ -22,10 +22,10 @@ from the checklist in "Pi: what the owner does". | `backup/Dockerfile`, `backup/backup.sh`, `backup/healthcheck.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion | | `pi/` | **The Pi project** (copied to `~/kvitt` on the Kvitt Pi): `compose.yaml`, `.env.example`; git-ignored `pi/.env`, `pi/secrets/`, `pi/out/` | | `npm/kvitt.conf` | The complete NPM Custom Nginx Configuration block of step 4, as a file to open and paste (placeholder `KVITT_APP_KEY`) | -| `build-images.sh` | Mac fallback for the image build: linux/arm64, push to the Gitea registry | +| `build-images.sh` | The image build (the normal path, D-211): on the Mac with Podman, linux/arm64, push to the Gitea registry | | `publish-apk.sh` | Mac: release APK, mapping and `latest.json` into `pi/out/apk/` for the owner to copy to the Pi | | `RESTORE.md` | Restore from a Drive backup, step by step (SPEC §10.9) | -| `../.gitea/workflows/build-images.yaml` | Gitea Actions image build (the normal path) | +| `../.gitea/workflows/build-images.yaml` | Gitea Actions image build on the Pi 3 runner (the fallback since D-211) | | `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`, `rclone.conf`, optional `registry.env` (and `ha_ssh_key(.pub)`, unused since D-120). Never committed | All Mac commands below run from the repository root of the main checkout (`/Users/jhag/projects/kvitt`). @@ -163,8 +163,8 @@ The key goes to the Pi as `~/kvitt/secrets/fcm-service-account.json` ("Pi: what | Where | What runs | Who runs it | |---|---|---| -| Gitea Actions (runner label `host`, a Raspberry Pi) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server` and `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") | -| Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) | +| Gitea Actions (runner label `host`, a Raspberry Pi; the fallback since D-211) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server` and `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") | +| Owner's Mac | `infra/build-images.sh v1.0.N` (the image build, the normal path, D-211), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) | | Kvitt Pi, `/home/jacobhagstedt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, and the bind-mounted `data/` (SQLite, pictures, `apk/`), `backups/`, `import/`; nothing outside this folder (D-125) | The owner, in their own SSH session, with plain `docker compose` commands | | Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `:8080` over the LAN (D-120); the app gate closes every request from outside the LAN without the app key (D-126) | The owner, in NPM's web UI (steps 4 and 4a) | | Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 5) | @@ -265,7 +265,11 @@ confirmation by name and size, local deletion only after both files were confirm `last-success` unchanged when the upload failed; the leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h. (The Caddy and cert-sync rehearsals of that day concern the removed stack.) -### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`) +### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`), the fallback + +Since 2026-10-24 (D-211) images are built on the Mac (next section); this workflow on the Pi 3 runner stays as the +fallback (about three hours per run, and the runner's disk fills). A push that touches its paths queues a run; the +owner cancels it in Gitea (the repository, Actions) when the Mac build is used. Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` with `kvitt_cicd` (a user-level secret of the repository owner, created by the owner; @@ -298,14 +302,41 @@ keeps the cargo registry and target directory in BuildKit cache mounts, so only takes 48 s on the M4 Mac); later runs reuse the builder's cargo cache. If a build fails for memory, add swap on the runner Pi, or build on the Mac with `infra/build-images.sh`. -### Mac fallback: `infra/build-images.sh [tag]` +### Image build on the Mac: `infra/build-images.sh v1.0.N` (the normal path, D-211) -Builds the same two images for linux/arm64 and pushes them when `infra/secrets/registry.env` exists -(`KVITT_REGISTRY=gitea.lan/jacobh2`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` with -write:package); otherwise it builds locally and prints the exact push commands. Tag: the short git SHA by default, -plus `latest`. Refuses a dirty tree and does not build while a Gradle build holds the gradle-serial lock. With -Podman (this Mac) it pushes with `--tls-verify=false`; with Docker it creates a buildx builder that allows plain -HTTP to the registry. +Builds the two images for linux/arm64 and pushes them when `infra/secrets/registry.env` exists +(`KVITT_REGISTRY=gitea.lan/jacobh2`, `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` with write:package); +otherwise it builds locally and prints the exact push commands. The server build gets +`KVITT_VERSION_CODE=` as in the workflow (`f15fa23`). Refuses a dirty tree and does not +build while a Gradle build holds the gradle-serial lock. With Podman (this Mac) it pushes with `--tls-verify=false`; +with Docker it creates a buildx builder that allows plain HTTP to the registry. The engine test captures +`docker version` before matching it (`8fa9198`: a `grep -q` pipe under `pipefail` made it pick docker). + +```bash +cd /Users/jhag/projects/kvitt +git fetch --tags +git tag --sort=-v:refname | head -1 # highest tag on origin, for example v1.0.3 +infra/build-images.sh v1.0.4 # the next v1.0.N: kvitt-server and kvitt-backup, tagged v1.0.4 and latest +git tag v1.0.4 && git push origin v1.0.4 # the same tag on the built commit, so the workflow's numbering continues +``` + +Tag rule: the next `v1.0.N` after the highest `v1.0.*` tag on `origin` (`v1.0.3` = `8fa9198`, the first Mac +build). The script ends with the deploy hint for the Pi (step 7). It prints no image digests; the digest comparison +under Operations applies to workflow builds. + +**Registry port.** From the Mac the registry is `gitea.lan` on port 80 (plain HTTP), so `registry.env` says +`KVITT_REGISTRY=gitea.lan/jacobh2`. The Pi's own `.env` keeps `KVITT_REGISTRY=gitea.lan:3001/jacobh2`, the port it +reaches the same registry on locally; both name the same images. + +**Podman and the plain-HTTP registry** (owner's allowance 2026-10-24, outside the repository). Podman refuses +`gitea.lan` until it is marked insecure, in both places (the Mac and the Podman machine, where builds and pushes +run). Both files are in place since 2026-10-24; repeat this only on a new Mac or a recreated Podman machine: + +```bash +mkdir -p ~/.config/containers/registries.conf.d +printf '[[registry]]\nlocation = "gitea.lan"\ninsecure = true\n' > ~/.config/containers/registries.conf.d/kvitt.conf +podman machine ssh "sudo mkdir -p /etc/containers/registries.conf.d && printf '[[registry]]\nlocation = \"gitea.lan\"\ninsecure = true\n' | sudo tee /etc/containers/registries.conf.d/kvitt.conf" +``` ### Publishing an APK: `infra/publish-apk.sh` @@ -364,8 +395,9 @@ 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 +Images are built on the Mac (`infra/build-images.sh v1.0.N`, "Image build on the Mac" above, D-211). If the +fallback workflow runs instead, run `docker builder prune -af` on the runner Pi between two of its builds: 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) @@ -394,7 +426,7 @@ rm -r infra/pi/secrets # the originals stay in infra/secrets/ |---|---| | `HA_HOST` | LAN IPv4 address of the Home Assistant Pi (NPM), for example `192.168.1.20`, not `homeassistant.local` (router client list, or Home Assistant, Settings, System, Network) | | `KVITT_UID`, `KVITT_GID` | `id -u` and `id -g` on the Kvitt Pi as `jacobhagstedt` (default 1000, 1000) | -| `KVITT_TAG` | `v1.0.0`, or the newest `v1.0.N` the Gitea workflow pushed (Gitea, the repository, Packages, `kvitt-server`) | +| `KVITT_TAG` | The newest `v1.0.N` pushed (by `infra/build-images.sh` or the workflow; Gitea, the repository, Packages, `kvitt-server`) | | `KVITT_PUBLIC_HOST`, `KVITT_REGISTRY`, `RCLONE_FOLDER_ID` | Already `kvitt.suorra.duckdns.org`, `gitea.lan/jacobh2`, `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` | `rclone.conf` is generated from your `rclone authorize "drive"` (remote `[gdrive]`, `scope = drive`); the Drive @@ -670,7 +702,8 @@ itself opens the link anywhere. The partner's invite, once the admin is paired: ### 7. Deploying a new version -After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi: +After `infra/build-images.sh v1.0.N` on the Mac pushed the images (the normal path, D-211; or the fallback workflow +pushed `v1.0.N`), on the Pi: ```bash cd ~/kvitt @@ -682,7 +715,7 @@ docker compose ps && curl -s localhost:8080/health Continue only when the backup ended with `backup: ok`. The images come over plain HTTP from `gitea.lan` (D-001), so compare the pulled digests (Operations, "Image digests") with the workflow's "Print image digests" step for that -run (D-117). Rollback: set the previous tag in `.env` the same way and run `docker compose pull && docker compose +run (D-117; a Mac build prints none). Rollback: set the previous tag in `.env` the same way and run `docker compose pull && docker compose up -d`; if a migration had partially applied, restore the pre-deploy backup (`infra/RESTORE.md`). ### Fallback (SPEC §10.5.3): when NPM is down @@ -740,8 +773,8 @@ or a leaked key (rotate it, 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. +* Image builds (2026-10-24, D-211, revises D-156): on the Mac with `infra/build-images.sh v1.0.N`; the workflow on + the Pi runner is the fallback. ### Troubleshooting