From 4bcff2cf8940f1d2556c605461f4d3f6809e7080 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 19 Oct 2026 20:04:09 +0200 Subject: [PATCH] SPEC v0.21: D-120 Nginx Proxy Manager instead of Caddy and cert-sync; D-121 Android host property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §10.5 rewritten (10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback: NPM down); Caddy and cert-sync text replaced in §1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.4, §10.7, §10.10, §11.2 to §11.4, OQ-3, OQ-6, E-7 and §14.7; the Kvitt host name is KVITT_PUBLIC_HOST. D-121: kvitt.publicHost, RELEASE_SERVER_URL and @ReleaseServerUrl (AC-2.6, AC-2.7, §10.6, §13.3 S-1). Decisions D-120 and D-121 with pointers in D-023, D-103 to D-106 and D-117; HANDOFF; prompts/README; a superseded-in-part note on PLAN-M2c-pi; dated D-120 notes under M-2, L-1, L-2, L-8 and I-5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- PLAN-M2c-pi.md | 2 + SPEC.md | 242 ++++++++++++++++------------- docs/HANDOFF.md | 64 ++++---- docs/decisions.md | 14 +- docs/validation/security-review.md | 10 ++ prompts/README.md | 6 +- 6 files changed, 191 insertions(+), 147 deletions(-) diff --git a/PLAN-M2c-pi.md b/PLAN-M2c-pi.md index adc0425..6bca6dd 100644 --- a/PLAN-M2c-pi.md +++ b/PLAN-M2c-pi.md @@ -1,5 +1,7 @@ # PLAN-M2c-pi: Infra on the Pi (D-001) +> **Superseded in part by D-120 (SPEC v0.21, 2026-10-19).** TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi: Caddy, cert-sync, the Caddyfile, the Home Assistant SSH key and the router forward to the Kvitt Pi are gone. Where this plan names them, SPEC §10 and `infra/README.md` are current; the plan is kept as the record of what M2c-pi was briefed with. + Runs after M8 lands and before M10 (D-001, §11.1). ## 1. Goal diff --git a/SPEC.md b/SPEC.md index 1360251..bf42fa6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.20** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**. +Version **0.21** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. @@ -24,7 +24,7 @@ Kvitt is a shared-expense app for one household of exactly two people living in It consists of: * A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements. -* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://icehack.asuscomm.com:8443`. The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates. +* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://:8443`, where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates. Goals for v1: @@ -106,7 +106,7 @@ Each requirement has acceptance criteria (AC). An AC is satisfied only when a te The server starts with an empty database. The household is created from the command line on the Pi. -* AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https://icehack.asuscomm.com:8443/i/` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0. +* AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https://:8443/i/` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0. * AC-1.2 `kvitt-server bootstrap` on a database that has users prints `already initialized` to stderr and exits 2. It creates nothing. * AC-1.3 Running `kvitt-server bootstrap` again before the first code is used cancels the previous `admin_setup` invite and prints a new one. * AC-1.4 Accepting the `admin_setup` invite (FR-2) creates the `admin` user with the given display name and the first device, and returns a device token. @@ -120,8 +120,8 @@ The server starts with an empty database. The household is created from the comm * AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which. * AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050). * AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=` (followed by `&from=` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". -* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://icehack.asuscomm.com:8443/i/[?from=…]` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL`, `https://icehack.asuscomm.com:8443`), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6). -* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending. +* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://:8443/i/[?from=…]` (the manifest placeholder `publicHost`, D-121; unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL` = `https://:8443`, D-121), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6). +* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `RELEASE_SERVER_URL` in release builds, D-121) and the code. Codes are normalised per §8.3 before sending. * AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any `https://` server a link or the user names; what S-1 shows then is in AC-2.6 (D-116). * AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). As built (M8), the request is made after the first successful sync after pairing. * AC-2.10 A `partner` pairing (E-3 `invite_kind` = `partner`, D-086) continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. Every other pairing goes to Home, including a member pairing a second phone with a `device` invite. @@ -383,10 +383,14 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). └───────┬──────────┘ └────────┬─────────┘ │ HTTPS :8443 (bearer token) │ ▼ ▼ - router: external 8443 ──► Kvitt Pi :8443 (80/443 go to the Home Assistant Pi) - ┌───────────────────────── Raspberry Pi 3, docker compose ─────────────────────────┐ - │ caddy (TLS, :8443) ──► server (axum, :8080) ──► /data/kvitt.sqlite (WAL) │ - │ cert-sync (ssh pull from HA Pi nightly, reload caddy) │ + router: external 8443 ──► Home Assistant Pi :443 (external 80 too, for Let's Encrypt) + ┌──────────────── Home Assistant Pi ────────────────┐ + │ Nginx Proxy Manager (TLS, Let's Encrypt, D-120) │ + └─────────────────────────┬─────────────────────────┘ + │ plain HTTP over the LAN, :8080 + ▼ + ┌──────────────────── Kvitt Pi: Raspberry Pi 3, docker compose ─────────────────────┐ + │ server (axum, :8080 published on the LAN) ──► /data/kvitt.sqlite (WAL) │ │ backup (sqlite .backup + gzip nightly ──► rclone ──► Google Drive) │ └───────────────────────────────────────────────────────────────────────────────────┘ │ FCM HTTP v1 (service account) ▲ Pi pulls images @@ -498,7 +502,7 @@ Cargo workspace (directory `server/`): ## 7. API outline -Base URL: `https://icehack.asuscomm.com:8443` (the value of `KVITT_PUBLIC_URL`). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1). +Base URL: `https://:8443` (the value of `KVITT_PUBLIC_URL`; `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), D-120). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1). | # | Method | Path | Auth | Purpose | |---|---|---|---|---| @@ -579,10 +583,10 @@ Two trusted users. The server is on the public internet on port 8443. Threats in ### 8.5 Transport -* TLS terminates in Caddy on port 8443 with the Let's Encrypt certificate for `icehack.asuscomm.com` (§10.5). TLS 1.2 minimum, Caddy defaults otherwise. -* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the fallback of §10.5.3 is active, in which case the pinned Caddy root replaces the system anchors for `icehack.asuscomm.com` only. The pinned-root config is in `android/app/src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082). +* TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF); the router forwards external TCP 8443 to NPM's 443 (§10.1, §10.5, D-120). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120). +* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). That config's domain is still the literal `icehack.asuscomm.com`; if the fallback is ever prepared it must follow `kvitt.publicHost` (D-121). The pinned-root config is in `android/app/src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082). * Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082). -* The server trusts `X-Forwarded-For` only from the Caddy container (the request's peer address on the compose network) to get the client IP for rate limiting. +* The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (checked by `install.sh`), and from that peer the server takes the last `X-Forwarded-For` entry as the client IP for rate limiting (D-120; the server is unchanged). A request from any other peer counts against the peer's own address. ### 8.6 Rate limiting and input limits @@ -600,7 +604,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * Server logs contain: timestamp, level, request method, route template (not the raw path with codes), status, latency, device id, error code. They never contain tokens, invite codes, display names, descriptions, notes, amounts or picture bytes. * The phone never logs personal content, debug builds included (`CLAUDE.md`). Log tags use the prefix `Kvitt`. -* Caddy's access log and its error log replace the `/i/` path segment and every query string (the inviter's name in `?from=`) with `REDACTED` through a `request>uri` filter; the `Authorization` header is redacted by Caddy's default (§10.5.2, D-117). +* Nginx Proxy Manager's access log (`proxy-host-_access.log` inside NPM) records the full `$request_uri`, query string included, on every path except `/i/`; the `location ^~ /i/` of the Custom Nginx Configuration has `access_log off` and `error_log /dev/null crit`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. NPM logs no request headers, so the `Authorization` token is never logged. The API's other query parameters (`since`, `limit`, `size`, `v`) hold no personal content (§10.5.2, D-120, revises D-117). * The push sender logs one info line `push sent` per message with aggregates only: `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line is at debug (D-113). * FCM payloads carry ids and types only (§14.6). * Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. The owner decided on 2026-10-19 not to encrypt them with an rclone `crypt` remote and to keep the token's `drive` scope (security review M-1 closed as an accepted risk, §8.1, `docs/validation/security-review.md`). @@ -614,8 +618,8 @@ Two trusted users. The server is on the public internet on port 8443. Threats in ### 8.9 Server hardening * The server container runs as uid/gid `10001`, with a read-only root filesystem, `/data` as the only writable volume, `/secrets` read-only, no Linux capabilities, `no-new-privileges`. -* The `caddy` container runs as uid 10002 with supplementary group 1000, a read-only root filesystem, every capability dropped except `NET_BIND_SERVICE`, `no-new-privileges` and tmpfs `/config` and `/tmp` (§10.2, D-117). -* Only Caddy publishes a port (8443). The server, Caddy's admin API and the sidecars are reachable only on the internal compose network. +* The TLS-terminating process is the owner's Nginx Proxy Manager on the Home Assistant Pi; its hardening is that install's, outside this repository (D-120). +* Only the server publishes a port (8080 on the Kvitt Pi's LAN; the router forwards only to NPM). The `backup` sidecar is reachable only on the internal compose network. Anyone on the home LAN can reach `http://:8080` directly; such requests count against their own address (§8.6, D-120). * SQLite `PRAGMA foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` on every connection. --- @@ -865,55 +869,51 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed. -**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts `infra/dev/secrets/` (git-ignored) read-only at `/secrets`, into which `infra/dev/up.sh` copies only `fcm-service-account.json` from `infra/secrets/`, so the server container sees no other secret (D-117; a folder rather than the file, so a missing key leaves the folder empty and push disabled, §10.4); its env file is `infra/dev/.env.example` (no `RCLONE_REMOTE` since `fc6126c`: the development backup never uploads); its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is `infra/pi/.env.example` (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `b7b7483`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. +**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no proxy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts `infra/dev/secrets/` (git-ignored) read-only at `/secrets`, into which `infra/dev/up.sh` copies only `fcm-service-account.json` from `infra/secrets/`, so the server container sees no other secret (D-117; a folder rather than the file, so a missing key leaves the folder empty and push disabled, §10.4); its env file is `infra/dev/.env.example` (no `RCLONE_REMOTE` since `fc6126c`: the development backup never uploads); its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is `infra/pi/.env.example` (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `b7b7483`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. -**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (`2476667`); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is `infra/README.md` "Pi: what the owner does" (nine steps). +**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (`2476667`); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is `infra/README.md` "Pi: what the owner does" (nine steps). Since D-120 (`d05f13a`, `62f5279`, landed with `9231a1d` on 2026-10-19) TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi: the bundle has no Caddyfile, no Caddy and no cert-sync, and step 6 of the checklist sets up the NPM proxy host (§10.5). ### 10.1 Hosts and network | Host | Role | Facts | |---|---|---| -| Kvitt Pi | Runs the compose stack | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104), static LAN address. LAN address: a placeholder in the bundle's `.env`, filled by the owner (OQ-3, D-100); the Mac does not reach it over SSH | -| Home Assistant Pi | Owns ports 80/443 and the Let's Encrypt certificate (certbot) for `icehack.asuscomm.com` | Reached by cert-sync (on the Kvitt Pi) over SSH. Address, SSH port and user and certificate directory: placeholders in the bundle's `.env` (`HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`), filled by the owner (OQ-3, D-100) | +| Kvitt Pi | Runs the compose stack; publishes the server's port 8080 on the LAN for NPM (D-120) | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104), static LAN address. LAN address: a placeholder in the bundle's `.env` (`KVITT_PI_HOST`), filled by the owner (OQ-3, D-100); also NPM's Forward Hostname / IP; the Mac does not reach it over SSH | +| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to `:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the bundle's `.env`, filled by the owner (OQ-3), and is the server's `KVITT_TRUSTED_PROXY`. NPM is also the proxy host for Home Assistant under `icehack.asuscomm.com` and selects the proxy host by name, so Kvitt has its own name. The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH | | Owner's M4 Mac | Builds release APKs (`infra/publish-apk.sh`); builds images only as the fallback of the Gitea workflow (`infra/build-images.sh`, D-104); runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker | -| Router (ASUS) | `icehack.asuscomm.com` DDNS | Forwards external TCP 8443 to Kvitt Pi TCP 8443 (HUMAN step) | +| Router (ASUS) | `icehack.asuscomm.com` DDNS (Home Assistant's name); the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) points at the same public address (a DuckDNS name is being set up) | Forwards external TCP 8443 to the Home Assistant Pi's TCP 443 (NPM); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (HUMAN step, D-120) | | Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them | The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them. ### 10.2 Compose stack -File `infra/pi/compose.yaml` (compose project name `kvitt`), installed by `infra/pi/install.sh` to `/opt/kvitt/compose.yaml` on the Pi together with `/opt/kvitt/Caddyfile` and `/opt/kvitt/.env` (mode 0600, from `infra/pi/.env.example`) (D-103; was `infra/compose.yaml` and `infra/env.example`). Services: +File `infra/pi/compose.yaml` (compose project name `kvitt`), installed by `infra/pi/install.sh` to `/opt/kvitt/compose.yaml` on the Pi together with `/opt/kvitt/.env` (mode 0600, from `infra/pi/.env.example`) (D-103; was `infra/compose.yaml` and `infra/env.example`; no Caddyfile since D-120). Services: | Service | Image | Ports / volumes | Memory limit | Healthcheck | |---|---|---|---|---| -| `caddy` | official `caddy` image, pinned by version and digest: `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af` (D-104) | publishes `8443:8443`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `certs:/certs:ro`; `caddy_data:/data` | 64 MiB | `wget -qO- http://localhost:2019/config/ >/dev/null` | -| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); no published port (listens on 8080 internally) | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) | -| `cert-sync` | `${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG}` | `certs:/certs`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `/opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro`; `/opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro` | 32 MiB | both cert files exist and are non-empty | +| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); publishes `8080:8080` on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}` | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) | | `backup` | `${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}` | `data:/data`; `backups:/backups`; `/opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro` | 96 MiB | last successful run marker newer than 26 hours, or no run yet since start | -* Named volumes: `data` (SQLite, pictures, APKs), `certs`, `caddy_data`, `backups`. -* `caddy` `depends_on` `cert-sync` with `condition: service_healthy` and `server` with `condition: service_started`. +* Named volumes: `data` (SQLite, pictures, APKs), `backups` (`certs` and `caddy_data` removed by D-120). * Every service: `restart: unless-stopped`, logging driver `json-file` with `max-size: 10m`, `max-file: 3`, `TZ=Europe/Stockholm`. * One internal bridge network `kvitt`. -* The `cert-sync` container runs as uid 1000; its secret files on the Pi (`/opt/kvitt/secrets/ha_ssh_key`, `ha_known_hosts`) have mode 0600 and are owned by 1000:1000 (D-023). The `backup` container runs as uid 10001, the server's uid, on the Pi as on the development stack, because `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns; so `/opt/kvitt/secrets/rclone.conf` has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a temporary copy of it on every run (D-105). `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to 10001; `/certs` to 1000:1000 with mode 0750; Caddy's `caddy_data` to 10002, D-117, extending D-105). The FCM key keeps the mode of §10.7 (0440, root:10001). -* The `caddy` container is non-root (D-117): `user: "10002:10002"` with `group_add: ["1000"]` (cert-sync's group, to read the 0640 files in the 0750 `/certs`), `read_only: true`, `cap_drop: [ALL]` and `cap_add: [NET_BIND_SERVICE]`, `no-new-privileges`, tmpfs `/config` and `/tmp`. Port 8443 needs no capability, but the image's `/usr/bin/caddy` carries the file capability `cap_net_bind_service=ep` and the kernel refuses to exec it when that capability is not in the bounding set (checked 2026-10-18), so it is the one capability kept. +* The `backup` container runs as uid 10001, the server's uid, on the Pi as on the development stack, because `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns; so `/opt/kvitt/secrets/rclone.conf` has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a temporary copy of it on every run (D-105). `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to 10001). The FCM key keeps the mode of §10.7 (0440, root:10001). These two are the only secret files on the Pi since D-120. +* `install.sh` removes what an install of the Caddy and cert-sync stack of before D-120 left: `/opt/kvitt/Caddyfile`, `/opt/kvitt/secrets/ha_ssh_key`, `/opt/kvitt/secrets/ha_known_hosts`, the `caddy` and `cert-sync` containers (`--remove-orphans`) and the volumes `kvitt_certs` and `kvitt_caddy_data`, and says so (D-120). -`.env` keys (exhaustive, D-103). Read by `compose.yaml`: `KVITT_REGISTRY` (`gitea.lan/jacobh2`), `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`. Read by `install.sh` only, never by the containers: `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (empty for anonymous pull). Informational, used in the owner's `scp` commands on the Mac and read by nothing on the Pi: `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. Unfilled values are `CHANGE_ME` placeholders; `install.sh` refuses a `.env` that still contains one. +`.env` keys (exhaustive, D-103). Read by `compose.yaml`: `KVITT_REGISTRY` (`gitea.lan/jacobh2`), `KVITT_TAG`, `KVITT_PUBLIC_HOST` (the Kvitt host name, owner-chosen, recorded in HANDOFF; `install.sh` requires a host name and refuses `icehack.asuscomm.com`), `HA_HOST` (NPM's LAN IPv4 address; `install.sh` requires an IPv4 literal), `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`. `HA_PORT`, `HA_USER` and `HA_CERT_DIR` were removed with cert-sync (D-120). Read by `install.sh` only, never by the containers: `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (empty for anonymous pull). Informational, used in the owner's `scp` commands on the Mac and in NPM, read by nothing on the Pi: `KVITT_PI_HOST` (also NPM's Forward Hostname / IP), `KVITT_PI_SSH_USER`. Unfilled values are `CHANGE_ME` placeholders; `install.sh` refuses a `.env` that still contains one. ### 10.3 Images, registry and build -* Image names (D-104): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-cert-sync`, `${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` (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), `sh` steps, Alpine `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with the repository secret `kvitt_cicd` (created by the owner), 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, `actions/checkout@v4` with `fetch-depth: 0`. The token reaches `docker login` through the environment, never expanded inside a shell line (security review L-8, `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; if that push fails, adding `write:repository` is the owner's decision). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which must match what the Pi records (D-117). Triggers: a push touching `server/**`, `infra/cert-sync/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch`. 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 what the deploy script takes; 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`). The first push to `origin` (2026-10-18) triggered the first run; its result and duration are still to be reported by the owner. 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 three 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. +* 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` (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), `sh` steps, Alpine `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with the repository secret `kvitt_cicd` (created by the owner), 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, `actions/checkout@v4` with `fetch-depth: 0`. 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 with a user-level secret ("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; if that push fails, adding `write:repository` is the owner's decision). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which must match what the Pi records (D-117). Triggers: a push touching `server/**`, `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 what the deploy script takes; 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`). The first run on the runner (2026-10-19) got as far as the login (container, `apk add` and checkout worked) and failed there with the env-based form; the run triggered by the `9231a1d` push tests the fixed login step, and its result and duration are still to be reported by the owner. 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. * `server` image: multi-stage. 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. -* `cert-sync` image: `alpine` with `openssh-client-default`, `curl`, `openssl` (PEM and key checks, D-104) and `tzdata`, and the shell script `infra/cert-sync/sync.sh`, run once at start by `infra/cert-sync/entrypoint.sh` and then daily by supercronic. * `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`. -* Both sidecars schedule with supercronic instead of busybox `crond` (D-104): they run as non-root users, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedules are unchanged (`0 4 * * *` cert-sync, `30 3 * * *` backup). -* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Caddy `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`; Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for both sidecars; cert-sync packages openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. Docker Engine on the Kvitt Pi comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`. +* The backup sidecar schedules with supercronic instead of busybox `crond` (D-104): it runs as a non-root user, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (`30 3 * * *`). +* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for the backup sidecar; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. The Caddy pin and the cert-sync packages left with D-120; Nginx Proxy Manager is the owner's existing install, and the block of §10.5.2 was written against NPM v2.16.0's templates. Docker Engine on the Kvitt Pi comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`. * Bundle scripts (D-103), run by the owner on the Kvitt Pi (D-100), with shared helpers in `infra/pi/lib.sh`: - * `infra/pi/install.sh`, run from the copied bundle, idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secret files with the modes and owners of §10.2 and §10.7, adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (restarting Docker when that changed) and logs in to the registry when `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` are set, copies `compose.yaml`, `Caddyfile`, `.env` and the scripts to `/opt/kvitt`, sets the volume owners, pulls, starts the stack and waits for it. A newer bundle is installed by copying it again and re-running `install.sh`; a `KVITT_TAG` that `deploy.sh` set in `/opt/kvitt/.env` is kept, so re-running an older bundle never rolls the server back to the bundle's tag (`b647faf`). It records the pulled image digests as `deploy.sh` does (D-117). - * `/opt/kvitt/deploy.sh [--skip-backup]`: runs `backup-now.sh` first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with `--skip-backup`, for a rollback while Drive is down); keeps the current tag in `/opt/kvitt/previous-tag` for rollback; sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull`, prints the registry digest of every image the stack runs and appends one line ` ` per image to `/opt/kvitt/deployed-digests`, printing a WARNING when a reference recorded before now has a different digest (expected only for a moving tag such as `latest`; the registry is plain HTTP on the LAN, so a swapped image would otherwise go unnoticed, security review L-9, D-117), then `docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. + * `infra/pi/install.sh`, run from the copied bundle, idempotent: checks the `.env` (no `CHANGE_ME` left, `KVITT_PUBLIC_HOST` a host name other than `icehack.asuscomm.com`, `HA_HOST` an IPv4 address), creates `/opt/kvitt/{secrets,import,apk}`, installs the secret files with the modes and owners of §10.2 and §10.7, adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (restarting Docker when that changed) and logs in to the registry when `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` are set, copies `compose.yaml`, `.env` and the scripts to `/opt/kvitt`, removes the leftovers of the Caddy and cert-sync stack (§10.2), sets the volume owners, pulls, starts the stack and waits for it, then checks `/health` on port 8080 and through NPM (§10.5.1; through NPM a failure is expected before the proxy host exists). A newer bundle is installed by copying it again and re-running `install.sh`; a `KVITT_TAG` that `deploy.sh` set in `/opt/kvitt/.env` is kept, so re-running an older bundle never rolls the server back to the bundle's tag (`b647faf`). It records the pulled image digests as `deploy.sh` does (D-117). + * `/opt/kvitt/deploy.sh [--skip-backup]`: runs `backup-now.sh` first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with `--skip-backup`, for a rollback while Drive is down); keeps the current tag in `/opt/kvitt/previous-tag` for rollback; sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull`, prints the registry digest of every image the stack runs and appends one line ` ` per image to `/opt/kvitt/deployed-digests`, printing a WARNING when a reference recorded before now has a different digest (expected only for a moving tag such as `latest`; the registry is plain HTTP on the LAN, so a swapped image would otherwise go unnoticed, security review L-9, D-117), then `docker compose up -d`, waits for `server` to be healthy, prints `GET http://localhost:8080/health` and then `/health` through NPM (a failure through NPM is a WARNING: the deploy succeeded, but Kvitt is not reachable from outside, D-120). * `/opt/kvitt/bootstrap.sh` (first install: the admin invite of FR-1), `/opt/kvitt/backup-now.sh` (one backup run, succeeds only when `last-success` changed, §10.9), `/opt/kvitt/install-apk.sh` (§10.6). * The Pi's Docker daemon must trust `gitea.lan` as an insecure (plain HTTP) registry (`insecure-registries`). `install.sh` adds it with sudo, run by the owner (D-103). @@ -925,10 +925,10 @@ Environment variables (exhaustive): |---|---|---| | `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite`, `pictures/`, `apk/` | | `KVITT_BIND` | `0.0.0.0:8080` | Listen address | -| `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Used in invite links and the landing page | +| `KVITT_PUBLIC_URL` | `https://:8443`, set by the Pi's compose from the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) (D-120) | Used in invite links and the landing page. The built-in fallback in code (`DEFAULT_PUBLIC_URL` in `config.rs`) is never used on the Pi, where compose always sets the variable; the development stack sets `http://:` | | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project | -| `KVITT_TRUSTED_PROXY` | `caddy` | Host name whose resolved address may set `X-Forwarded-For`. The server must start when the name does not resolve (as on the development stack, which has no Caddy); then no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) | +| `KVITT_TRUSTED_PROXY` | `caddy` | IP literal or host name whose resolved address may set `X-Forwarded-For` (not a CIDR). The default `caddy` no longer resolves and means no proxy; compose sets it to `HA_HOST`, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy); then no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) | | `RUST_LOG` | `info` | tracing filter | The time zone `Europe/Stockholm` is a constant in code, not configuration. @@ -955,70 +955,93 @@ Run them on the Pi with `docker compose exec server kvitt-server ` (or ### 10.5 TLS -#### 10.5.1 Certificate sync +TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}:8443`. Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 6; no agent and no script touches NPM. -* The Home Assistant Pi renews the Let's Encrypt certificate for `icehack.asuscomm.com` (its own certbot; Kvitt does not touch it). -* `cert-sync` runs `sync.sh` at container start and daily at 04:00 Europe/Stockholm. It connects to `${HA_USER}@${HA_HOST}:${HA_PORT}` with the key `/secrets/ha_ssh_key` and `StrictHostKeyChecking=yes` against `/secrets/known_hosts`, always requests the remote command `tar -C ${HA_CERT_DIR} -cf - fullchain.pem privkey.pem` and unpacks the tar stream into a temp directory (D-106). -* The key is dedicated to Kvitt (`infra/secrets/ha_ssh_key` on the Mac, git-ignored). It is added restricted with `restrict,command="tar -C -cf - fullchain.pem privkey.pem"` (variant A, required; optionally `from=""` too). A plain key for `root` (the old variant B) is not acceptable (D-117, security review M-2): the add-ons log in as root, and the key would give an unrestricted root shell on the Home Assistant Pi. If the add-on refuses the restricted line, a dedicated non-root user that can only read `HA_CERT_DIR` (`HA_USER`) gets the plain key; if neither is possible, the fallback is §10.5.3. Because the script always requests that exact command, both forms work unchanged (D-106). Which form the add-on accepts is reported by the owner at checklist step 3 and recorded in `infra/README.md`. -* If both files parse as PEM, the certificate's not-after is in the future, the private key matches the certificate, the certificate does not expire before the installed one, and the SHA-256 of either differs from `/certs`, the script moves them into `/certs` (mode 0640, group readable by Caddy) and reloads Caddy: `curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile`. A key not matching its certificate, or a certificate expiring before the installed one, is refused and the installed pair is kept (D-106). At first start Caddy is not up yet (it waits for `cert-sync` to be healthy) and loads the files when it starts, so an unreachable Caddy admin API is logged and is not a failure (D-106). -* Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist. +#### 10.5.1 Nginx Proxy Manager -#### 10.5.2 Caddyfile +NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), Hosts, Proxy Hosts, Add Proxy Host: -File `infra/pi/Caddyfile` (D-103), installed as `/opt/kvitt/Caddyfile`. As of `7fb86f5` (D-117) it reads: +| Tab | Field | Value | +|---|---|---| +| Details | Domain Names | `` | +| Details | Scheme | `http` | +| Details | Forward Hostname / IP | `` (the Kvitt Pi's LAN IPv4 address) | +| Details | Forward Port | `8080` | +| Details | Cache Assets | off | +| Details | Block Common Exploits | on | +| Details | Websockets Support | off | +| Details | Access List | Publicly Accessible | +| Custom Locations | (none) | left empty | +| SSL | SSL Certificate | Request a new Certificate: Let's Encrypt, HTTP challenge (Use DNS Challenge off), the owner's email address, Terms of Service accepted where shown | +| SSL | Force SSL | on | +| SSL | HTTP/2 Support | on | +| SSL | HSTS Enabled | on | +| SSL | HSTS Sub-domains | off | +| SSL | Trust Upstream Forwarded Proto Headers | off | +| Advanced | Custom Nginx Configuration | the block of §10.5.2, exactly, comments included | -``` -# Kvitt Caddyfile (SPEC §10.5.2, D-117). Installed as /opt/kvitt/Caddyfile; cert-sync reloads it through the admin API. -{ - admin 0.0.0.0:2019 - auto_https off - # Caddy's own error log (for example a 502 when the server is down) carries the request too: same URI filter. - log default { - output stderr - format filter { - request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` - wrap json - } - } +* Plain HTTP on the LAN: NPM forwards plain HTTP over the home LAN to `:8080`, accepted by the owner (D-120). Anyone on the home LAN can also reach that port directly; such requests count against their own address for the limits of §8.6, and only `HA_HOST` may set `X-Forwarded-For` (§8.9). +* Client IP: NPM's request to the server carries `X-Forwarded-For: , `. The server takes the last entry only when the TCP peer is `KVITT_TRUSTED_PROXY`, which compose sets to `HA_HOST`, NPM's LAN IPv4 address (an IP literal, checked by `install.sh`, because the container cannot resolve an mDNS name such as `homeassistant.local`); the server is unchanged (D-032, D-115, D-119). The router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no SNAT, for traffic arriving on the LAN interface), so the per-IP limits see real addresses (checked once, §10.10). The server does not use `X-Forwarded-Proto` or `Host`: links are built from `KVITT_PUBLIC_URL` (E-12). +* `X-Real-IP` (accepted): NPM's `nginx.conf` takes the client address from `X-Real-IP` when the connection comes from 10/8, 172.16/12 or 192.168/16 (and the CDN ranges it downloads), so a device on the home LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the router's forward keeps the source address), so their `X-Real-IP` is ignored. +* HSTS: NPM sends `Strict-Transport-Security` with `max-age=63072000; preload` (two years, NPM's value) at server level. A browser then uses only HTTPS for ``, on every port, and refuses certificate warnings for it; turning HSTS off later does not clear browsers that already stored it. Because the name is Kvitt's own nothing else is affected, but anything ever served under `` (any port) must be HTTPS with a valid certificate too. The app does not rely on HSTS (release builds allow no cleartext, §8.5); it protects the landing page in a browser. +* Health checks: `install.sh` and `deploy.sh` check `http://localhost:8080/health` on the Kvitt Pi, then `/health` through NPM (`curl --resolve :443: https:///health`, with the certificate checked); a failure through NPM is a warning only. `install.sh` also removes the leftovers of the Caddy and cert-sync stack (§10.2). + +#### 10.5.2 Custom Nginx Configuration + +The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of `62f5279` it reads (`infra/README.md`, step 6): + +```nginx +# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host , Advanced tab, Custom Nginx Configuration. +# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413. +client_max_body_size 6m; +# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server. +proxy_read_timeout 60s; +# Response headers (D-117). They live at server level, so no location below may use add_header (nginx would then +# drop all of these in it); NPM's HSTS line (SSL tab) sits at the same level. The server's own copy is hidden, so +# each header appears once (the landing page sends Referrer-Policy itself). +proxy_hide_header Content-Security-Policy; +proxy_hide_header X-Content-Type-Options; +proxy_hide_header X-Frame-Options; +proxy_hide_header Referrer-Policy; +add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; +add_header X-Content-Type-Options nosniff always; +add_header X-Frame-Options DENY always; +add_header Referrer-Policy no-referrer always; +# Replaces NPM's own "location /" (NPM leaves it out when this field has one), whose add_header X-Served-By would +# drop the headers above. $forward_scheme, $server and $port are NPM's Details tab values. +location / { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass $forward_scheme://$server:$port$request_uri; } - -icehack.asuscomm.com:8443 { - tls /certs/fullchain.pem /certs/privkey.pem - request_body { - max_size 6MB - } - # Set after the server's response headers (defer), so a header the server also sends appears once. - header { - defer - Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" - X-Content-Type-Options nosniff - X-Frame-Options DENY - Referrer-Policy no-referrer - } - reverse_proxy server:8080 - # Access log: the invite code in /i/ and every query string (the inviter's name in ?from=) become - # REDACTED (SPEC §8.7); the Authorization header is redacted by Caddy's default. - log { - output stdout - format filter { - request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` - wrap json - } - } +# Invite links /i/?from=: neither the code nor the inviter's name may reach a log (SPEC §8.7). NPM's +# access log format writes the whole $request_uri, query string included, so this path is not logged at all, and +# its error log (which would carry the request line on a 502) goes nowhere. +location ^~ /i/ { + access_log off; + error_log /dev/null crit; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass $forward_scheme://$server:$port$request_uri; } ``` -* Logs (D-117, security review L-1): the access log and Caddy's own error log (`log default`, which also carries the request, for example on a 502) pass `request>uri` through a regexp filter that turns `/i/` into `/i/REDACTED` and every query string into `?REDACTED`, so neither invite codes nor the inviter's name (`?from=`) reach a log (§8.7). Caddy logs must not include the `Authorization` header (Caddy redacts it by default). Verified on the Mac on 2026-10-18 with the pinned image: the header is logged as `"Authorization":["REDACTED"]` and the token never appears; TLS 1.1 is refused. -* Response headers (D-117): `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` as above, set with `defer` (after the server's own headers), so a header the server also sends (the landing page's `Referrer-Policy: no-referrer`, AC-2.5) appears once. +* Response headers (D-117): `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` are set at server level with `always`, and the server's own copies are hidden with `proxy_hide_header`, so each header appears once (the landing page sends `Referrer-Policy: no-referrer` itself, AC-2.5). NPM's HSTS line from the SSL tab sits at the same level. +* Its own `location /`: nginx drops every `add_header` inherited from the server level in a location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would silently drop the four headers. NPM leaves its default location out when the Custom Nginx Configuration contains `location /` (NPM matches it with the regex `^(?:.*;)?\s*?location\s*?\/\s*?{`), so the block brings its own, without `add_header`. Its `proxy_pass` uses NPM's Details values (`$forward_scheme`, `$server`, `$port`). If a later NPM release changes the proxy host template, the headers are checked again (§10.10). +* `client_max_body_size 6m`: above the server's own limits (1 MiB, 5 MiB for `PUT /me/picture`, §8.6), which answer `payload_too_large` themselves; larger bodies get NPM's 413. `proxy_read_timeout 60s`: the API has no long-poll. +* Logs (§8.7): NPM writes one access log line per request to `/data/logs/proxy-host-_access.log` inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host, `"$request_uri"` (the path with its query string), `[Client
]`, length, gzip ratio, `[Sent-to ]`, user agent and referer. It logs no request headers, so the `Authorization` token is never logged. Query strings are logged for every path except `/i/`; the API's other query parameters are `since` and `limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `location ^~ /i/` turns the access log off and sends its error log to `/dev/null`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. Errors on other paths (for example a 502 while the server is down) go to `proxy-host-_error.log` with the request line. +* Verified on the Mac on 2026-10-19 (D-120): `nginx -t` (nginx:alpine 1.31.7) of the block inside a minimal server block and inside a copy of NPM v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the §10.5.1 settings, both successful; a Podman rehearsal of the server image built from `server/Dockerfile` (arm64, hardened as on the Pi) behind that nginx with a self-signed certificate, 21 checks passed: the four headers and `Strict-Transport-Security` once each over HTTP/2 and no `X-Served-By`; one `Referrer-Policy` on the landing page; `http://` answered 301 to `https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); the rate limit applied per forwarded client and a forged `X-Forwarded-For` did not escape it; the invite code, the name and `/i/` absent from nginx's access and error logs after `/i/`, `//i/` and `/%69/` requests and a 502 on `/i/`. Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for the Kvitt host name, the router forward. -#### 10.5.3 Fallback: Caddy internal CA +#### 10.5.3 Fallback: NPM down -Used only if the certificate sync cannot be made to work. Documented in `infra/README.md`, not deployed by default. - -* Caddyfile site block uses `tls internal` instead of the file pair. -* The Caddy root certificate (`/data/caddy/pki/authorities/local/root.crt` in `caddy_data`) is copied once to `android/app/src/release/res/raw/kvitt_root_ca.crt`. This file is a public certificate and is committed. -* The release network security config gets a `domain-config` for `icehack.asuscomm.com` whose trust anchors are only `@raw/kvitt_root_ca`. Switching is a build property `kvitt.tls.pinnedRoot=true` in `android/gradle.properties`, which adds `android/app/src/releasePinnedRoot/res` to the release resources (D-082). -* Caddy's internal root is valid 10 years; intermediates renew automatically. +* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over `https://:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and nothing there needs to change; the phones keep working on their full local copy and sync when NPM is back (FR-15). +* On the home Wi-Fi only debug builds can reach the server directly, at `http://:8080` (AC-2.8 allows `http://` to private LAN addresses in debug builds only). Release builds require HTTPS with a publicly trusted certificate (§8.5): there is no LAN fallback for the release app. A debug build is a separate app (`se.hagstedt.kvitt.debug`) with its own pairing and data, not a stand-in for the release app. +* If NPM cannot renew the Let's Encrypt certificate, it is fixed in NPM (SSL Certificates, the certificate, Renew Now; port 80 must reach NPM). +* The pinned-root switch of D-082 (`kvitt.tls.pinnedRoot=true` in `android/gradle.properties`, which adds `android/app/src/releasePinnedRoot/res`, a `domain-config` trusting only `@raw/kvitt_root_ca` whose domain is still the literal `icehack.asuscomm.com` and must follow `kvitt.publicHost` if this fallback is prepared, D-121) remains available for a self-made root whose certificate would be uploaded to NPM as a Custom certificate. It is not prepared: no such root exists and none is committed. ### 10.6 APK hosting and release @@ -1044,6 +1067,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R } ``` +* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, path prefix `/i/`, `android:autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://:8443` (`link/ReleaseServer.kt`); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1). Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (§10.5); the owner may instead set the key in `android/local.properties`. * The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`. * Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. * Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103). @@ -1063,7 +1087,7 @@ No new command-line tool is installed for this (D-022): `gcloud` exists on the M The agent may write these git-ignored files under `infra/secrets/` in the main checkout and act on the Google Cloud project `kvitt-1337` for steps 2 to 6 (D-022); nothing else outside the repository. -State after M2c-dev (`c885aba`): steps 1 to 6 are done. Firebase is added to project `kvitt-1337`; Android apps exist for `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; the APIs `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com` and `iam.googleapis.com` are enabled; the service account is `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with role `roles/firebasecloudmessaging.admin` (a role named `roles/firebasemessaging.admin` does not exist); `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` (both clients) exist in the main checkout. `infra/README.md` documents the REST calls for repeating or inspecting the setup. Step 7 is part of the owner's run of the M2c-pi bundle (checklist step 6). +State after M2c-dev (`c885aba`): steps 1 to 6 are done. Firebase is added to project `kvitt-1337`; Android apps exist for `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; the APIs `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com` and `iam.googleapis.com` are enabled; the service account is `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with role `roles/firebasecloudmessaging.admin` (a role named `roles/firebasemessaging.admin` does not exist); `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` (both clients) exist in the main checkout. `infra/README.md` documents the REST calls for repeating or inspecting the setup. Step 7 is part of the owner's run of the M2c-pi bundle (checklist step 5, `install.sh`, since D-120). The FCM free tier is sufficient; no billing account is required. @@ -1148,14 +1172,15 @@ Restore (documented step by step in `infra/RESTORE.md`; its commands run through ### 10.10 Operations -* Logs: `docker compose logs `. +* Logs: `docker compose logs ` on the Kvitt Pi; NPM's own log files are inside NPM on the Home Assistant Pi (§10.5.2). * Disk: SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), 5 kept. * Upgrades: the Gitea workflow builds and pushes a `v1.0.N` tag (or `infra/build-images.sh` on the Mac), then the owner runs `/opt/kvitt/deploy.sh ` on the Pi. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen). * Before every deploy, `deploy.sh` runs a backup and waits for `last-success` to update: the rule "back up before a deploy with a migration" is applied to every deploy, because the Pi cannot tell beforehand whether a tag carries one (D-103). * Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; the deploy script is `infra/pi/deploy.sh`, installed as `/opt/kvitt/deploy.sh` (D-103); the owner's operations table is in `infra/README.md`. Agents prepare and check the bundle on the Mac and never connect to the Pi. * Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing. * Image digests: `/opt/kvitt/deployed-digests` holds one line per image and deploy (§10.3, D-117); a WARNING from `deploy.sh` or `install.sh` that a tag changed digest is investigated before the stack is trusted. -* Client IPs (once, after the router forward, D-117): with the phone off Wi-Fi, open `/health`, then check that Caddy's access log shows the mobile network's public address in `remote_ip`, not a `172.*` or `10.*` bridge address; otherwise every internet client shares one per-IP limit of §8.6 (security review I-5; the command is in `infra/README.md`'s operations table). +* Client IPs (once, after the router forward, D-117, D-120): with the phone off Wi-Fi, open `https://:8443/health`, then within a minute on the Kvitt Pi `sudo conntrack -L -p tcp --orig-port-dst 8080` (package `conntrack`, installed once): in every line the first `src=` is `HA_HOST` and the reply half (from the second `src=`) has `dst=` with no `172.*` address, so the server sees NPM's address and trusts its `X-Forwarded-For`; a `172.*` address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are in `infra/README.md`'s operations table). +* Headers through NPM (after the first setup and after every NPM upgrade): `curl -sS -D - -o /dev/null --resolve :443: https:///health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy` and `strict-transport-security` once each and no `x-served-by` (§10.5.2). --- @@ -1226,7 +1251,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 | | M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 | | M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) | -| M2c-pi | Infra on the Pi (D-001, D-100) | Bundle landed `2476667` 2026-10-18 (D-103 to D-106); the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the `gitea.lan` registry, the cert-sync key on the Home Assistant SSH add-on, `rclone authorize "drive"`), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at landing: shellcheck clean on 18 scripts, compose config valid, the cert-sync arm64 image builds; rehearsed on the Mac with Podman: cert-sync install, renew, skip and refuse cases, Caddy reload through the admin API, `Authorization` redacted in Caddy's log, TLS 1.1 refused, backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: the Home Assistant add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose). On the Pi, run by the owner from the checklist: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the README checklist: install Docker if missing, add `gitea.lan` to `insecure-registries`, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, `rclone authorize "drive"`, provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`, the cert-sync key `infra/secrets/ha_ssh_key`. Remaining: the owner runs the bundle, the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, the add-on key variant A or B, `.env` and secrets, copy and host key, `install.sh`, router forward, first backup and bootstrap, deploy). No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) | +| M2c-pi | Infra on the Pi (D-001, D-100, D-120) | Bundle landed `2476667` 2026-10-18 (D-103 to D-106); the NPM rework (D-120: `4f69ae0` workflow login, `d05f13a` stack without Caddy and cert-sync, `62f5279` and `9231a1d` README) landed `9231a1d` 2026-10-19; the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack (`server`, `backup`), env template with the OQ-3 values and `KVITT_PUBLIC_HOST` as placeholders, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, Docker, `.env` and secrets, `install.sh`, the NPM proxy host and its Custom Nginx Configuration of §10.5, the router forward 8443 → Home Assistant Pi 443, trust of the `gitea.lan` registry, first backup and bootstrap, deploy), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at the NPM rework: shellcheck clean on every script under `infra/` and `tools/`, compose config valid for `infra/pi/` and `infra/dev/`, `nginx -t` of the §10.5.2 block against NPM v2.16.0's templates, the Podman rehearsal of the server behind nginx with 21 checks passed (§10.5.2); from 2026-10-18 still valid: backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: NPM's UI and Let's Encrypt for the Kvitt host name, the router forward, the Gitea workflow run, pulls from `gitea.lan`, Drive, real Docker compose). On the Pi, run by the owner from the checklist: `install.sh` ends healthy on port 8080 and through NPM, `https://:8443/health` answers from the phone on mobile data without a certificate warning; one backup file appears in the Drive folder | Choose the Kvitt host name (a DuckDNS name is being set up); copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, `.env` and secrets, copy, `install.sh`, the NPM proxy host, router forward 8443 → Home Assistant Pi 443, first backup and bootstrap, deploy), answering yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`. No allowances for agents on the Pis, Home Assistant, NPM, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) | | M3 | Server core API | Landed `7869ec4` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a | | M4 | Server sync, audit, push, scheduler | Landed `15056dc` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) | | M5 | Android data layer | Landed `8f0e156` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | @@ -1263,11 +1288,11 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c, `a0c25b0`, D-099). The `fid` request was taken on 2026-10-18 (`f56dcbe`, D-096): the server sends the installation id in FCM HTTP v1 `message.fid` (§14.6). -* **M2c-dev / M2c-pi** (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) as the `infra/pi/` bundle the owner runs, and runs last, after M8 and the phone test, before M10. +* **M2c-dev / M2c-pi** (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); M2c-pi builds everything that concerns the Pi (registry trust, TLS through the owner's Nginx Proxy Manager since D-120, router, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) as the `infra/pi/` bundle the owner runs, and runs last, after M8 and the phone test, before M10. * **M4** (landed `15056dc`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. * **M8** (landed `cb832a7` and `3d9eea8`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI had no partner-invite subcommand then; `kvitt-server invite-partner` followed in `8660b7f`, D-113); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. **M8b** (landed `328c466`) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a `VisualTransformation` with an exact offset mapping (cursor bug fixed, unit test `InviteCodeFieldTest`); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); `KvittTopAppBar` pads for the status-bar inset through a `windowInsets` parameter and `KvittScaffold` follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed in `6eb3f85` (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the `fid` field followed in `f56dcbe` (D-096). **M8e** (landed `a59abeb`, D-107) turned S-19's account, payer and split chips into `EditorOptionRows`; the unused `AccountChip`, `PayerChip` and `SplitChip` were removed in `1328afe`. **M8f** (landed `956908a`, D-108) made navigation instant (`NavTransitions` in `:app`). * **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 at the repository root and referenced from `android/local.properties` (D-024, D-101). -* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed `2476667` on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the remaining part is HUMAN, the owner running the bundle, and M10 depends on it. +* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed `2476667` on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the NPM rework (D-120, `4f69ae0` to `9231a1d`, 2026-10-19) replaced Caddy and cert-sync with a proxy host in the owner's Nginx Proxy Manager (§10.5); `PLAN-M2c-pi.md` carries a note that it is superseded there. The remaining part is HUMAN, the owner running the bundle, and M10 depends on it. * **M9** (landed `50339ae`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`. * **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `aa765bb`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `6eaccec`) and M12c (landed `b09f322`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6. * **M12d** (D-059, landed `557af7e`) added the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (`docs/ux/design-spec.md` §3.7 and §6) are no longer needed. @@ -1275,7 +1300,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **M12f** (landed `dc7a296` to `a0259ab`, D-109 to D-112, D-116) took the Android findings of the AC-coverage audit and the security review (§11.4); see the §11.2 row. The S-1 confirm step for an unusual server (security review L-6) was not built: it needs a design state (OQ-6). * **Server gap pass** (landed `8660b7f`, D-113): `kvitt-server invite-partner` (§10.4), which closes the ops request of the M8 test; tests for `invite-partner`, `migrate` (schema at `SCHEMA_VERSION`, idempotent), `healthcheck` (200, 503, nothing listening) and the SIGTERM drain of `serve`; the push sender's `push sent` info line (§8.7); `tools/contracts-check.py` follows `invite_kind` (D-086), `app_version_name` (D-090) and `fid` (D-096), 134 checks pass. * **Server overhaul** (landed `8103dae` to `b985ce9`, D-114, D-115): shared stored-row reads and inserts (`src/api/rows.rs`, `src/sync/rows.rs`), shared invite creation and CLI plumbing, one test harness (`tests/common/`, §14.7), clippy pedantic wins, synchronous `tasks::start` (§6.3), `not_implemented` no longer produced (§14.3.2); the security fixes H-1 (D-115), M-3 and L-4 (D-114). Gate at landing: 268 server tests; all crates at their latest versions. -* **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` (§10.3); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3); `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), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). +* **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` (§10.3); `--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), `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. @@ -1284,7 +1309,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-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a `KvittWorkerFactory` test. -* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 must be fixed before port 8443 is opened to the internet. +* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 must be fixed before port 8443 is opened to the internet. 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. --- @@ -1296,10 +1321,10 @@ Only things that cannot be decided without the owner. Everything else in this sp |---|---|---|---| | OQ-1 | Answered (D-001): the registry is a Gitea instance on another Pi at `http://gitea.lan`, plain HTTP, reachable only on the home Wi-Fi; images are built by Gitea Actions from the owner's example workflow (provided; `.gitea/workflows/build-images.yaml`, D-104). | M2c-pi | answered | | OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and `Total balance` / `Totalsumma` row, payment category `Payment` / `Betalning`, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. | M9, M11 | answered | -| OQ-3 | Answered by placeholders (D-100): the LAN address of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there (expected `/ssl` on Home Assistant OS); which SSH add-on is used. The owner fills them in the M2c-pi bundle's `.env` on the Pi; no agent needs them. | M2c-pi | answered | +| OQ-3 | Answered by placeholders (D-100, revised by D-120): the LAN address of the Kvitt Pi (`KVITT_PI_HOST`) and the LAN IPv4 address of the Home Assistant Pi, where NPM runs (`HA_HOST`); with D-120 also the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF). ~~SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there; which SSH add-on is used~~ (cert-sync removed by D-120). The owner fills them in the M2c-pi bundle's `.env` on the Pi; no agent needs them. | M2c-pi | answered | | OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered | | OQ-5 | Answered 2026-10-18 and implemented (D-099, `a0c25b0`): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. | M10 | answered | -| OQ-6 | Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than `https://icehack.asuscomm.com:8443` (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. | M10 | Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116) | +| OQ-6 | Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. | M10 | Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116) | --- @@ -1336,7 +1361,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp | Id | Screen | Must show | Actions | |---|---|---|---| -| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | +| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled; in release builds with `RELEASE_SERVER_URL`, which `:feature:onboarding` receives through the Hilt qualifier `@ReleaseServerUrl` for the prefill and for the non-default-server check of AC-2.6, D-121); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | | S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue | | S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings | | S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | @@ -2226,7 +2251,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default **E-7 `GET /me`** (device). 200: ```json -{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://icehack.asuscomm.com:8443" } +{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://:8443" } ``` **E-8 `PATCH /me`** (device). Request `{ "display_name": string }`. 200 `User`. Errors: `invalid_request`, `validation_failed`. @@ -2742,9 +2767,9 @@ android/ app/ infra/ build-images.sh publish-apk.sh README.md RESTORE.md .shellcheckrc - pi/{compose.yaml, Caddyfile, .env.example, .gitignore, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh} pi/.env, pi/secrets/, pi/out/ (git-ignored) + pi/{compose.yaml, .env.example, .gitignore, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh} pi/.env, pi/secrets/, pi/out/ (git-ignored) dev/{compose.yaml, .env.example, lib.sh, build.sh, up.sh, logs.sh, backup.sh, copy-google-services.sh} dev/data/, dev/backups/ (git-ignored) - cert-sync/{Dockerfile, sync.sh, entrypoint.sh} backup/{Dockerfile, backup.sh, healthcheck.sh} secrets/ (git-ignored) + backup/{Dockerfile, backup.sh, healthcheck.sh} secrets/ (git-ignored) ``` Ownership (an agent edits only paths its milestone owns; anything else needs the orchestrator): @@ -2835,3 +2860,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op | 0.18 | 2026-10-18 | M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`), the server gap pass (`8660b7f`), the server overhaul (`8103dae` to `b985ce9`), the infra overhaul (`2fac1ec` to `e70fe0d`) and infra security fixes (`7fb86f5`), the AC-coverage audit and the security review. D-109: S-16 shows a rejected change's lost version read-only, `Rejection.lost: RejectedVersion?` with `RejectedVersion(row, accountName, payer, mode, shares, notes, deleted)` from `rejected_payload`, null when undecodable or a user or the category is missing; dialog "Din version", "Du tog bort den här", "Visa nuvarande version"; ledger rows take a null `onClick` (read-only); four `home_sync_lost_*` strings (§13.3 S-16, §14.2.4, §14.4.6, design-spec S-16). D-110: `AboutState.schemaVersion`; S-22 reads `/health` when it opens online and shows " (schema )" (`settings_about_server_version_schema`), header fallback offline (§13.3 S-22, §14.2.4, E-1, design-spec S-22). D-111: `SettlementRepository.start` and `recompute` return `SettleState?` (null without a session or partner), `save` returns `Invalid(payer, validation_failed)`; no throws (§14.2.4, AC-5.1). D-112: a failed E-17 send is retried after each successful sync until accepted, a newer FID replaces it (AC-13.7). D-113: `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin) and the `push sent` info line (§8.7, §10.4); `tools/contracts-check.py` 134 checks. D-116: notifications `VISIBILITY_PRIVATE` with the generic public version (new AC-13.9); L-6 kept as built, S-1's server row expanded for a non-default server, the confirm step listed as OQ-6 (AC-2.6, AC-2.8, §12). D-117: Caddy log redaction of `/i/` and query strings, `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (§8.7, §10.5.2, which now quotes `infra/pi/Caddyfile` at `7fb86f5`); Caddy as uid 10002 with group 1000, read-only, `cap_drop ALL` plus `NET_BIND_SERVICE`, tmpfs; `/certs` 0750 1000:1000, `caddy_data` 10002, extending D-105 (§8.9, §10.2); the development stack copies only `fcm-service-account.json` into `infra/dev/secrets/` (§10 intro); `/opt/kvitt/deployed-digests` with a WARNING on a changed digest and a workflow digest step; `kvitt_cicd` with `write:package` only (§10.3, §10.10); the plain root key (variant B) removed (§10.5.1); the `remote_ip` check (§10.10). D-114: pictures at most 16 000 000 pixels and 64 MB decoded, `image_invalid` (AC-4.4); `fcm_token` 1 to 512 characters, else `invalid_request` (§9.6, E-17). D-115: a token is accepted only after a 2xx, an unaccepted token counts against its IP, a 401 withdraws acceptance, entries pruned when their window expires; revises D-032 (§8.6). Without a decision id: the server overhaul (`8103dae` to `b985ce9`: shared row reads and inserts, invite creation and CLI helpers, one test harness `tests/common/`, clippy pedantic wins, synchronous `tasks::start`, `not_implemented` no longer produced; 268 server tests; all crates at their latest versions) (§6.3, §14.3.2, §14.7). Without a decision id: `install.sh` keeps the deployed `KVITT_TAG`, `--provenance=false`, the shared `tools/gradle-lock.sh`, `RCLONE_REMOTE` dropped from the development env (§10 intro, §10.3, §11.3); `strings.csv` 577 to 562 rows (§13.4); `localeFilters` en, sv (AC-19.1); `work-testing` and the screenshot harness fix (§6.2); release APK 10.0 MB (§10.6, §10.10); M12f row in §11.2 (941 tests); §11.4 validation reports (`docs/validation/ac-coverage.md`, `docs/validation/security-review.md`); the history rewrite and first push to `origin` (`18c527f`): every commit hash cited in this file is the rewritten one; `7c453d8` (the oxipng pass) no longer exists as a commit, its PNGs are in `18c527f`. | | 0.19 | 2026-10-18 | The Android overhaul (`e4e6e5d` to `c582363`). D-118: shared test support in `:core:design`'s test fixtures (`se.hagstedt.kvitt.design.testing`: `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with `Root`/`Screen`/`ScreenWhenIdle`, `MainDispatcherRule`, `withMain`) through `testImplementation(testFixtures(project(":core:design")))`, not a module, the architecture check rejects test fixtures outside test configurations; the convention plugin `kvitt.android.screenshots`; `format/Failures.kt`, `format/Limits.kt` and `savedSplitLabel` in `:core:design`; `KvittMotion` only the seesaw spring, `KvittIcon.Description` and unused dimens and shapes removed; four catalog entries removed; one `CopyCategoryKeywords` task (§6.2 `:core:design` row and Screenshot tests, §11.1, §11.3; design-spec §2.11 and the icon table). Without a decision id: overhaul outcomes in §11.3 (Android `c582363`, server `b985ce9`, infra `e70fe0d`); 941 tests, no golden changed, release APK 10.46 MB; pushed to `origin` at `c582363`; `infra/README.md` APK size about 10 MB; pre-rewrite hashes in `PLAN-*.md` replaced by the rewritten ones. | | 0.20 | 2026-10-19 | APK download rate limit (`96e4cbd`) and owner answers to the security review. D-119: E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own fixed window, not counted against the 60 per minute of the other unauthenticated endpoints, every request counted (a 404 included), 429 `rate_limited` with `Retry-After`, client IP as in D-032, in memory; E-5 and E-4 stay on the 60 (§8.6, §10.6, E-6). Without a decision id: security review M-1 closed by the owner (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in §8.1 and §8.7); L-3 fixed; the D-114 limits (16 megapixels, 512-character `fcm_token`) confirmed by the owner (§11.4). | +| 0.21 | 2026-10-19 | The NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`) and the Android host property (`d090c6c`). D-120: TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (`infra/cert-sync/**`, `infra/pi/Caddyfile`, images, volumes `certs` and `caddy_data`, secrets `ha_ssh_key` and `ha_known_hosts`, `.env` keys `HA_PORT`, `HA_USER`, `HA_CERT_DIR`); Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen, recorded in HANDOFF), `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`; router 8443 → NPM 443, nothing forwarded to the Kvitt Pi; the server publishes 8080 on the LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}`; the NPM proxy host and its Custom Nginx Configuration carry over D-117's headers and log redaction (§10.5 rewritten: 10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback: NPM down); health checks on 8080 and through NPM; the Gitea login interpolates the secret in the `run` line (reverses L-8's fix) (§1, AC-1.1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.5, §10.7, §10.10, §11.2, §11.3, §11.4, OQ-3, OQ-6, E-7, §14.7). D-121 (`d090c6c`): the Android app's public host is the build property `kvitt.publicHost` (Gradle property, else `android/local.properties`, else the placeholder `kvitt.example.invalid`), exposed as `BuildConfig.PUBLIC_HOST`, the manifest placeholder `publicHost` and `RELEASE_SERVER_URL` = `https://:8443`, injected into `:feature:onboarding` as `@ReleaseServerUrl` (AC-2.6, AC-2.7, §8.5, §10.5.3, §10.6, §13.3 S-1, OQ-6). Without a decision id: `cargo audit` in the server gate (`f1abb0d`). | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bf88801..77b53ff 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,9 +3,13 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `96e4cbd` (the APK download rate limit, D-119, landed after the SPEC v0.19 docs commit `3ef2c15`) plus the SPEC v0.20 docs commit from `agent-docs-v020` once it lands. `origin/main` (Gitea) at `96e4cbd`: everything up to the rate limit is pushed. +- `main` at `d090c6c` (the Android host property, D-121, on top of `9231a1d`, the end of the NPM rework, D-120) plus the SPEC v0.21 docs commit from `agent-docs-v021` once it lands. `origin/main` (Gitea) at `d090c6c`: everything is pushed (`9231a1d` triggered the Actions run of §3 item 1; `d090c6c` touches only `android/` and triggers none). - History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`. -- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119). +- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121). +- Landed since SPEC v0.20 (recorded in v0.21): + - `cargo audit` in the server gate (`f1abb0d`, no decision): the gate is `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test`; the pre-commit hook runs `cargo audit --no-fetch` only when `server/Cargo.lock` or a server `Cargo.toml` is staged. First run: 0 advisories over 319 crates. + - NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`; D-120): TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi; Caddy, cert-sync, the Caddyfile, the HA SSH key and the router forward to the Kvitt Pi are gone. Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant. Router external 8443 → HA Pi 443. The server publishes 8080 on the Kvitt Pi's LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}` (NPM's IPv4, checked by `install.sh`). SPEC §10.5 rewritten (NPM proxy host, the Custom Nginx Configuration with D-117's headers and the `/i/` log exclusion, fallback: no LAN path for release builds). Rehearsed on the Mac: `nginx -t` against NPM v2.16.0 templates, 21 checks of the server behind nginx passed. The Gitea login interpolates the secret in the `run` line (the env form came through empty on act_runner 0.6.1). + - Android host property (`d090c6c`, D-121): `kvitt.publicHost` (Gradle property, else `android/local.properties`, else `kvitt.example.invalid`), `BuildConfig.PUBLIC_HOST`, the manifest placeholder `publicHost`, `RELEASE_SERVER_URL`, `@ReleaseServerUrl` in `:feature:onboarding`. 944 Android tests. - Landed since SPEC v0.19 (recorded in v0.20): - APK download rate limit (`96e4cbd`, D-119, security review L-3): E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own window, not counted against the 60 of the other unauthenticated endpoints; a 404 counts; 429 `rate_limited` with `Retry-After`; E-5 and E-4 stay on the 60; unit and integration tests. - Landed since SPEC v0.18 (recorded in v0.19): @@ -15,50 +19,50 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Server gap pass (`8660b7f`, D-113): `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin), so a scripted partner needs no second admin device; `push sent` info line; CLI tests (`invite-partner`, `migrate`, `healthcheck`, SIGTERM drain); `tools/contracts-check.py` 134 checks. - Infra security (`7fb86f5`, D-117): Caddy log redaction of `/i/` and query strings, CSP and three more response headers, Caddy non-root (uid 10002, read-only, caps dropped), `/certs` 0750, dev stack mounts only the FCM key, `/opt/kvitt/deployed-digests` with a WARNING on change, `kvitt_cicd` with `write:package` only, the plain root key (old variant B) not acceptable. - M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116): S-16 lost version read-only, S-22 " (schema )" from `/health`, `SettlementRepository` returns null instead of throwing, E-17 retry after each successful sync, invite links dropped while paired, private notifications on the lock screen, `localeFilters` en/sv, `:data` tests (429, D-040, WorkManager via `work-testing`), the home/account screenshot harness fix (stale `sv_dark` goldens re-recorded), 19 unused string keys removed (`strings.csv` 562 rows). Gate at landing: 941 tests. Release APK 10.0 MB. -- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed. H-1 must be fixed before port 8443 is opened. +- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 must be fixed before port 8443 is opened. - 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: - - NPM rework (D-120 reserved, agent running): the owner runs Nginx Proxy Manager on the Home Assistant Pi, and NPM renews the Let's Encrypt certificate for `icehack.asuscomm.com`. Caddy and cert-sync are replaced by an NPM proxy host that forwards to the Kvitt Pi's server on LAN port 8080, so TLS terminates in NPM, the Kvitt Pi's compose stack loses `caddy` and `cert-sync`, and the `infra/pi/` bundle and its owner checklist change. Security review M-2 (the HA SSH key form for cert-sync) becomes moot and is closed. SPEC §10.5 and the other Caddy and cert-sync text are rewritten when D-120 lands (a docs pass records it; next free decision D-121). Open assumptions are §3 item 3. - - This docs pass (`agent-docs-v020`: SPEC v0.20, D-119, HANDOFF, `prompts/README.md`). - - `cargo-audit` in the server gate: the orchestrator is installing `cargo-audit` on the Mac; an agent adds it to the gate after the install. + - This docs pass (`agent-docs-v021`: SPEC v0.21, D-120, D-121, HANDOFF, `prompts/README.md`, the PLAN-M2c-pi note, dated notes in the security review). + - Dev server rebuild (orchestrator/agent). + - D-121 follow-ups: `:data` `DataModule.DEFAULT_SERVER_URL` is still the old literal `https://icehack.asuscomm.com:8443` (S-22's fallback) and moves to `AppBuildInfo.releaseServerUrl`; `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (follow-up agent running). + - Gitea Actions: the first run (2026-10-19) got as far as the login (container, `apk add`, checkout worked) and failed with "password is empty" (env-based form); the run triggered by the `9231a1d` push tests the fixed login step (owner, §3 item 1). - 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 `96e4cbd`. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`); the cert-sync key `infra/secrets/ha_ssh_key` exists (all git-ignored). The first push triggered the Gitea Actions workflow; its result is the owner's §3 item 1. -- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` (from `server/`). Both green on `main` as far as reported. +- Remote: `origin` on Gitea, SSH key works; pushed at `d090c6c`. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted). +- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 944 tests at `d090c6c`). - Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). - Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`. -- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server, with the build whose checks are §3 item 2; the release app `se.hagstedt.kvitt`, installed 2026-10-19, not yet paired (its checks are §3 item 2 as well). +- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, installed 2026-10-19: About and the release app opening were confirmed; release pairing is deferred to production (release builds require `https`, AC-2.8). - Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB. ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-docs-v020` (orchestrator): SPEC v0.20, D-119, HANDOFF, `prompts/README.md`. +- Land `agent-docs-v021` (orchestrator): SPEC v0.21, D-120, D-121, HANDOFF, `prompts/README.md`, PLAN-M2c-pi note, security-review notes. +- D-121 follow-ups (in flight): `:data` `DataModule.DEFAULT_SERVER_URL` → `AppBuildInfo.releaseServerUrl`; `infra/publish-apk.sh` passes `-Pkvitt.publicHost`. +- Literals left by D-120 and D-121 (agent, no plan yet): the server's `DEFAULT_PUBLIC_URL` (`https://icehack.asuscomm.com:8443`) and `DEFAULT_TRUSTED_PROXY` (`caddy`) in `config.rs`, `openapi.rs`'s `PUBLIC_SERVER_URL`, `contracts/openapi.yaml`'s server URL and the E-7 and E-12 examples, `server/README.md`, the fakes' `AboutState` URLs; harmless (compose always sets the variables) but they name Home Assistant's host; a contract change needs the §14.8 rule. The pinned-root config's domain (D-082) follows only if that fallback is prepared. +- `install.sh`'s last line says "Next: README step 7 (NPM proxy host)"; the NPM step is 6 (agent, one-line fix in `infra/pi/install.sh`). +- Possible firewall rule on the Kvitt Pi limiting access to port 8080 to the HA Pi (`HA_HOST`); not done, not decided (owner). +- `PLAN-M2c-pi.md` is superseded where it names Caddy and cert-sync (note at its top); SPEC §10 and `infra/README.md` are current. - Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit). - AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and the phones (M2c-pi run, M10). - A `KvittWorkerFactory.createWorker` test (Android agent, no plan yet). - Security review L-6: an S-1 design state if the owner wants a confirm step for an unusual server (OQ-6, default not in v1; owner, §4). - Security review L-10: `forbidden` or `not_found` for the other user's device id (owner decision, §4). -- Tooling: `cargo-audit` in the server gate (agent, after the orchestrator's install on the Mac, in flight). - 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 first runs). -- Land the NPM rework (D-120) when its agent reports, then a docs pass records D-120 and rewrites SPEC §10.5 and the Caddy and cert-sync text (orchestrator). -- The owner's answers to the phone checks of §3 item 2 (debug app checks; release app pairing with a device invite the orchestrator provides). -- Owner runs the Pi bundle on the Kvitt Pi: deferred by the owner on 2026-10-19 ("when it makes sense to move over to the Pi"); the bundle and its checklist are rewritten by D-120 first; record the router-forward answer in `infra/README.md` then (the cert-sync key-form question is closed). -- Plans (SPEC §11.2 v0.20), 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, server gap pass, infra overhaul, infra security, the server overhaul and the Android overhaul: landed. - - Next: a server image with the H-1 fix before the router forward; the NPM rework (D-120) reshapes the Pi bundle; the owner runs it on the Kvitt Pi when he moves over (D-100, deferred); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). +- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs). +- Owner runs the Pi bundle on the Kvitt Pi: deferred by the owner on 2026-10-19 ("when it makes sense to move over to the Pi"); the checklist is the D-120 one (nine steps, NPM at step 6, router at step 7). +- Plans (SPEC §11.2 v0.21), 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 and its NPM rework, server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit` and the Android host property: landed. + - Next: the owner runs the bundle on the Kvitt Pi when he moves over (D-100, deferred; a server image with the H-1 fix before the router forward); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). ## 3. HUMAN steps pending (relay in chat) -1. Gitea, the repository, Actions: did the run "Build Kvitt images" triggered by the first push succeed (yes/no), and how long did it take? Parked by the owner ("come back to it"). If the run's tag push failed with `kvitt_cicd` limited to `write:package`: may `write:repository` be added (yes/no)? -2. Phone checks, answers pending (yes/no each): - 1. Debug app (`se.hagstedt.kvitt.debug`): screen switches are instant, with no fade or slide; Inställningar, Om Kvitt shows the server version as "… (schema 3)"; the recurring editor shows Konto, Betalt av, Delning as three fixed rows. - 2. Release app (`se.hagstedt.kvitt`, installed 2026-10-19): it opens to "Para ihop" without a crash; then pair it with the device invite the orchestrator provides; Home shows "Test"; add one expense and watch it sync. -3. NPM questions (for D-120; the agent proceeds on the assumptions): is `icehack.asuscomm.com` already a proxy host in Nginx Proxy Manager for another service (assumed free)? Does the router forward external 8443 to NPM's 443 or to a dedicated NPM port (assumed 443)? -4. Pi checklist, later: deferred by the owner ("when it makes sense to move over to the Pi"). The steps of `infra/README.md` "Pi: what the owner does" are rewritten by D-120 (no cert-sync key, the router forward goes to NPM) and relayed in chat when the owner moves over. +1. Gitea, the repository, Actions: the run "Build Kvitt images" triggered by the `9231a1d` push: did the step "Log in to Gitea Container Registry" pass (yes/no), and did the whole run succeed (yes/no)? If the step "Tag and Push Version" failed with a permission error: may `write:repository` be added to `kvitt_cicd` (yes/no)? +2. The DuckDNS name for Kvitt: tell the orchestrator once it exists. An agent then puts it into `infra/pi/.env` as `KVITT_PUBLIC_HOST` and into `android/local.properties` as `kvitt.publicHost` on the owner's word (D-120, D-121). +3. Phone checks: done. About ("… (schema 3)") confirmed and the release app opens confirmed (2026-10-19). Release pairing is deferred to production: release builds require `https` (AC-2.8), so the release app pairs only once the Pi runs behind NPM. +4. Pi checklist, later: deferred by the owner ("when it makes sense to move over to the Pi"). The nine steps of `infra/README.md` "Pi: what the owner does" (D-120: NPM proxy host at step 6, router forward 8443 → HA Pi 443 at step 7) are relayed in chat when the owner moves over. ## 4. Owner questions (asked, unanswered) -- §3 item 1: the workflow result and duration (parked), and `write:repository` if the tag push failed. -- §3 item 2: the debug and release app checks. -- §3 item 3: the two NPM questions (assumed: `icehack.asuscomm.com` free in NPM; external 8443 to NPM 443). +- Kvitt's host name (`KVITT_PUBLIC_HOST`, a DuckDNS name being set up; §3 item 2). +- `write:repository` for `kvitt_cicd`, only if the workflow's tag push fails (§3 item 1). - OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". - Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`. - State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built? @@ -67,9 +71,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Nothing. ## 6. Numbering -- Decisions: D-001 to D-119 used, except D-008 and D-009 (never used); D-120 reserved by the running NPM agent. Next free: **D-121**. -- Spec version: v0.20 written; next **v0.21**. -- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.20. +- Decisions: D-001 to D-121 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-122**. +- Spec version: v0.21 written; next **v0.22**. +- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.21. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index fba4341..b16fad6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -27,7 +27,7 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-020 | 2026-10-17 | No bundled font: the app uses the Material 3 default typeface. No licence step. | PLAN-M6 open point (b); avoids a font licence clearance. | SPEC §6.2, §11.2, §13.4; PLAN-M2b, PLAN-M6, PLAN-U1, PLAN-U2 | | D-021 | 2026-10-17 | `sync_mutations` pruning runs daily at 04:00 Europe/Stockholm (DST-aware), deleting rows whose `received_at` is older than 90 days. | PLAN-M4 open point (c); the spec said only "daily". | SPEC §14.1 (rules text, not the DDL), §14.4.9, §11.2; PLAN-M4 | | D-022 | 2026-10-17 | Development infrastructure (M2c-dev, landed `c885aba`). No new CLI installs: Firebase project and Android apps through the Firebase Management REST API with a `gcloud` access token, no Firebase CLI; `rclone` belongs to M2c-pi. The agent may write git-ignored secrets, `google-services.json` included, under `infra/secrets/`; M8 copies it into `android/app/` with `infra/dev/copy-google-services.sh`. Phone health check against the dev server is a HUMAN step. Gate: `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config`; `shellcheck $(find infra -name '*.sh')`. As built: dev stack without Caddy or TLS, bind-mounted data folder, the whole `infra/secrets/` mounted read-only at `/secrets` (a missing key file means push disabled), env file `infra/dev/.env.example` (the Pi's stays `infra/env.example` for M2c-pi), `kvitt-server healthcheck` used by compose, backups with `sqlite3 .backup` (§10.9). The server must start when `KVITT_TRUSTED_PROXY` does not resolve. Firebase on `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with `roles/firebasecloudmessaging.admin` (`roles/firebasemessaging.admin` does not exist). | Owner's tools already on the Mac; M2c-dev report. | SPEC §10 intro, §10.4, §10.7, §11.2, §11.3, §14.7; PLAN-M2c-dev, PLAN-M8 | -| D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, ~~`rclone.conf`~~) mode 0600 owned by uid 1000, the uid of the `cert-sync` ~~and `backup`~~ container (revised by D-105: `backup` runs as uid 10001 and `rclone.conf` is owned by 10001:10001); adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi | +| D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (~~`ha_ssh_key`, `ha_known_hosts`~~, ~~`rclone.conf`~~) mode 0600 owned by uid 1000, the uid of the ~~`cert-sync`~~ ~~and `backup`~~ container (revised by D-105: `backup` runs as uid 10001 and `rclone.conf` is owned by 10001:10001; revised by D-120: cert-sync and its secrets `ha_ssh_key` and `ha_known_hosts` are removed); adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi; revised by D-100, D-105, D-120 | | D-024 | 2026-10-17 | Release keystore: `keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000`, created by the owner ~~and kept outside the repository~~ (was `android/keystore/release.jks`; revised by D-101: at the repository root, git-ignored by `*.jks`); `local.properties` holds its absolute path. | §10.6 did not fix the key parameters (PLAN-M10 H-1); a keystore outside every checkout cannot be committed or deleted with a worktree. | SPEC §10.6, §11.2, §11.3; PLAN-M10 | | D-025 | 2026-10-17 | Production Splitwise import (M11): a backup first; user ids resolved by display name from the database; CSVs copied to the Pi into `/opt/kvitt/import/`, bind-mounted read-only into `server` as `/import`; the import runs as `docker compose run --rm --entrypoint kvitt-import server ...` with the server stopped (`--entrypoint` because the image's entrypoint is `kvitt-server`); CSVs deleted afterwards. The category table stays at `server/crates/kvitt-import/data/splitwise_categories.csv`. | PLAN-M11 open points (a) to (c); PLAN-M9 table location. | SPEC §10.2, §10.8, §11.2, §11.3; PLAN-M2c-pi, PLAN-M9, PLAN-M11 | | D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`8f0e156`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 | @@ -107,10 +107,10 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `2476667`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi | | D-101 | 2026-10-18 | The release keystore `kvitt-release.jks` (created with the D-024 `keytool` command) lives at the repository root of the main checkout, git-ignored by the `*.jks` pattern of `.gitignore` and never committed; `android/local.properties` references it by absolute path in `kvitt.release.storeFile` with the other three `kvitt.release.*` keys. The owner keeps a copy of the file and its password in their password manager. Revises D-024 ("outside the repository"). | Owner decision 2026-10-18; the `*.jks` ignore rule already keeps the file out of git. | SPEC §10.6, §11.2 (M10), §11.3; PLAN-M10 | | D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`bacbd18`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 | -| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `2476667`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` | -| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `b4d9467`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" | -| D-105 | 2026-10-18 | `backup` runs as uid 10001 on the Pi too, so it can open the WAL database (`sqlite3 .backup` needs the `-shm` file the server owns); `/opt/kvitt/secrets/rclone.conf` mode 0600 owned 10001:10001. `cert-sync` stays uid 1000 with `ha_ssh_key`, `ha_known_hosts` 0600 owned by 1000. `backup.sh` uses a temporary copy of the read-only `rclone.conf` so rclone can save refreshed tokens. `install.sh` chowns `/data`, `/data/apk`, `/backups` to 10001 and `/certs` to 1000 once. Reconciles SPEC §10.2; revises D-023 for `rclone.conf`. | M2c-pi report: uid 1000 could not back up the live database without loosening the server's files. | SPEC §10.2, §10.9; `infra/README.md` "Backup uid" | -| D-106 | 2026-10-18 | `sync.sh` always requests the `tar -C -cf - fullchain.pem privkey.pem` stream, so both `authorized_keys` variants (A restricted, B plain) work unchanged; it refuses a key not matching its certificate and a certificate expiring before the installed one; an unreachable Caddy admin API at first start is not a failure (Caddy loads the files when it starts). `backup.sh` uploads every pending `kvitt-*` / `pictures-*` file including leftovers of failed runs, confirms each by name and size with `rclone lsf`, deletes the local copies only after all are confirmed, and writes `last-success` only after that. | M2c-pi report: the add-on's support for `authorized_keys` options is unknown; rehearsal of renew, refuse and failed-upload cases on the Mac. | SPEC §10.5.1, §10.9 | +| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), ~~`Caddyfile`~~ (removed by D-120), and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `2476667`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` | +| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, ~~`kvitt-cert-sync`~~ (removed by D-120), `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, ~~`infra/cert-sync/**`~~ (dropped by D-120), `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `b4d9467`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); ~~the cert-sync image adds `openssl`~~. Pinned versions (2026-10-18): ~~`caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`~~, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, ~~openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9~~, supercronic 0.2.49 (revised by D-120: no Caddy pin and no cert-sync image or packages; only the backup sidecar uses supercronic); Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" | +| D-105 | 2026-10-18 | `backup` runs as uid 10001 on the Pi too, so it can open the WAL database (`sqlite3 .backup` needs the `-shm` file the server owns); `/opt/kvitt/secrets/rclone.conf` mode 0600 owned 10001:10001. ~~`cert-sync` stays uid 1000 with `ha_ssh_key`, `ha_known_hosts` 0600 owned by 1000.~~ (cert-sync removed by D-120) `backup.sh` uses a temporary copy of the read-only `rclone.conf` so rclone can save refreshed tokens. `install.sh` chowns `/data`, `/data/apk`, `/backups` to 10001 ~~and `/certs` to 1000~~ once. Reconciles SPEC §10.2; revises D-023 for `rclone.conf`. | M2c-pi report: uid 1000 could not back up the live database without loosening the server's files. | SPEC §10.2, §10.9; `infra/README.md` "Backup uid" | +| D-106 | 2026-10-18 | ~~`sync.sh` always requests the `tar -C -cf - fullchain.pem privkey.pem` stream, so both `authorized_keys` variants (A restricted, B plain) work unchanged; it refuses a key not matching its certificate and a certificate expiring before the installed one; an unreachable Caddy admin API at first start is not a failure (Caddy loads the files when it starts).~~ (revised by D-120: cert-sync removed) `backup.sh` uploads every pending `kvitt-*` / `pictures-*` file including leftovers of failed runs, confirms each by name and size with `rclone lsf`, deletes the local copies only after all are confirmed, and writes `last-success` only after that. | M2c-pi report: the add-on's support for `authorized_keys` options is unknown; rehearsal of renew, refuse and failed-upload cases on the Mac. | SPEC §10.5.1, §10.9 | | D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `1328afe`. Landed by M8e (`a59abeb`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 | | D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`956908a`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 | | D-109 | 2026-10-18 | S-16 shows a rejected change's lost version read-only. `Rejection` gains `lost: RejectedVersion?`, with `RejectedVersion(row: LedgerRow, accountName: String?, payer: User?, mode: SplitMode?, shares: List, notes: String, deleted: Boolean)` decoded in `:data` from `sync_rejections.rejected_payload` (the entity as pushed; `row` is the ledger row it would have been, `pending` false; for a settlement `accountName`, `payer` and `mode` are null and `shares` empty). `lost` is null when the payload cannot be decoded or its category or a user is not on the phone. "Öppna" on a rejection opens a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the read-only ledger row, account, payer, split and notes, the badge "Du tog bort den här" (`home_sync_lost_deleted`) for a refused deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; without `lost` it opens the current version as before. The `:core:design` ledger rows take a nullable `onClick` (null = read-only). Landed by M12f (`dc7a296`). UI contract change. | AC-coverage audit: S-16's "open a rejected change (read-only)" opened the current editable row, not the lost change. | SPEC §13.3 (S-16), §13.4, §14.2.4, §14.4.6; `docs/ux/design-spec.md` S-16 | @@ -121,6 +121,8 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-114 | 2026-10-18 | Upload and token limits. A picture upload (E-9, AC-4.4) must also be at most 16 000 000 pixels (width × height, for example 4000 × 4000) and decode to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder limits set as well (`MAX_PIXELS`, `MAX_DECODE_ALLOC` in `src/api/pictures.rs`), refused with `image_invalid`; the 8000 px per side and 5 MiB limits stay. `fcm_token` at E-17 is 1 to 512 characters after trimming (`FCM_TOKEN_MAX`); longer is `invalid_request` with field `fcm_token`, like the empty string (D-034); a Firebase Installation ID has 22 characters. Landed by the server overhaul (`baa5806`, `b985ce9`). | Security review M-3: 8000 × 8000 decodes to 256 MB RGBA, above the 160 MiB container. L-4: a device could store a token up to the 1 MiB body limit, sent to FCM in every push. | SPEC §4 (AC-4.4), §9.6, §14.3.4 (E-17) | | D-115 | 2026-10-18 | Rate-limit token acceptance, revising D-032: a bearer token is accepted only when a response to it is 2xx; a request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back; a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries are pruned once their window expires, accepted or not; every 401 counts against the IP exactly once. Landed by the server overhaul (`725901c`), with unit tests per rule and an integration test of random tokens without the protocol header (60 × 426, then 429, the paired device unaffected). | Security review H-1: a random token without `X-Kvitt-Protocol` (426) became accepted for good, bypassed the IP limit and was never pruned, so a stranger could grow the in-memory map without bound. | SPEC §8.6 | | D-116 | 2026-10-18 | Notifications are posted with `VISIBILITY_PRIVATE` and a public version carrying the generic "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), so a secure lock screen shows no description, amount, account or name (new AC-13.9; `NotificationPrivacyTest`). L-6 (a link can prefill any server on S-1) stays as built: S-1 opens its server row expanded when the link's server is not the release default, release builds require `https://`, and a paired phone ignores links (D-084); a confirmation step would need a design state and is not in v1 (OQ-6). Landed by M12f (`2bd6b58`). | Security review L-7 and L-6. | SPEC §4 (AC-2.6, AC-2.8, AC-13.9), §12 (OQ-6) | -| D-117 | 2026-10-18 | Infra security fixes, landed `7fb86f5`. (1) Caddy's access log and error log (`log default`) filter `request>uri` with ``regexp `^(/i/)[^?]+\|([?]).+` `${1}${2}REDACTED` `` (`wrap json`), so invite codes and the `?from=` name never reach a log; `Authorization` stays redacted by default. (2) Deferred response headers `Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"`, `X-Content-Type-Options nosniff`, `X-Frame-Options DENY`, `Referrer-Policy no-referrer`. (3) The `caddy` container runs as `10002:10002` with `group_add` 1000, `read_only`, `cap_drop: [ALL]` plus `cap_add: [NET_BIND_SERVICE]` (the image's binary has that file capability and cannot be exec'd without it), `no-new-privileges`, tmpfs `/config` and `/tmp`; `install.sh` sets `/certs` 0750 1000:1000 and `caddy_data` to 10002 (extends D-105). (4) The development stack mounts `infra/dev/secrets/` (git-ignored), into which `up.sh` copies only `fcm-service-account.json`. (5) `deploy.sh` and `install.sh` print each pulled image's digest and append ` ` to `/opt/kvitt/deployed-digests`, with a WARNING when a recorded reference changed digest; the workflow prints the pushed digests. (6) `kvitt_cicd` needs only `write:package` (the tag push uses the checkout credentials). (7) The plain root key (variant B) for cert-sync is no longer acceptable: variant A (restricted) or a dedicated non-root user. (8) One-off check after the router forward that Caddy's `remote_ip` shows public addresses. | Security review at `18c527f`: L-1, L-2, L-5, L-9, M-2, I-5. | SPEC §8.7, §8.9, §10 intro, §10.2, §10.3, §10.5.1, §10.5.2, §10.10; `infra/pi/`, `infra/dev/`, `.gitea/workflows/build-images.yaml`, `infra/README.md` | +| D-117 | 2026-10-18 | Infra security fixes, landed `7fb86f5`. (1) ~~Caddy's access log and error log (`log default`) filter `request>uri` with ``regexp `^(/i/)[^?]+\|([?]).+` `${1}${2}REDACTED` `` (`wrap json`), so invite codes and the `?from=` name never reach a log; `Authorization` stays redacted by default.~~ (revised by D-120: NPM's `location ^~ /i/` without access or error log; NPM logs no request headers) (2) ~~Deferred~~ Response headers `Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"`, `X-Content-Type-Options nosniff`, `X-Frame-Options DENY`, `Referrer-Policy no-referrer` (revised by D-120: set at NPM's server level with the server's copies hidden). (3) ~~The `caddy` container runs as `10002:10002` with `group_add` 1000, `read_only`, `cap_drop: [ALL]` plus `cap_add: [NET_BIND_SERVICE]` (the image's binary has that file capability and cannot be exec'd without it), `no-new-privileges`, tmpfs `/config` and `/tmp`; `install.sh` sets `/certs` 0750 1000:1000 and `caddy_data` to 10002 (extends D-105).~~ (removed by D-120) (4) The development stack mounts `infra/dev/secrets/` (git-ignored), into which `up.sh` copies only `fcm-service-account.json`. (5) `deploy.sh` and `install.sh` print each pulled image's digest and append ` ` to `/opt/kvitt/deployed-digests`, with a WARNING when a recorded reference changed digest; the workflow prints the pushed digests. (6) `kvitt_cicd` needs only `write:package` (the tag push uses the checkout credentials). (7) ~~The plain root key (variant B) for cert-sync is no longer acceptable: variant A (restricted) or a dedicated non-root user.~~ (moot since D-120: no Home Assistant SSH key) (8) ~~One-off check after the router forward that Caddy's `remote_ip` shows public addresses.~~ (revised by D-120: the conntrack check of SPEC §10.10) | Security review at `18c527f`: L-1, L-2, L-5, L-9, M-2, I-5. | SPEC §8.7, §8.9, §10 intro, §10.2, §10.3, §10.5.1, §10.5.2, §10.10; `infra/pi/`, `infra/dev/`, `.gitea/workflows/build-images.yaml`, `infra/README.md` | | D-118 | 2026-10-18 | Shared test support lives in `:core:design`'s test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`: `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with capture modes `Root`/`Screen`/`ScreenWhenIdle`, `MainDispatcherRule`, `withMain`), consumed as `testImplementation(testFixtures(project(":core:design")))`; not a module, so the §6.2 graph is unchanged; the architecture check rejects test fixtures outside test configurations. The convention plugin `kvitt.android.screenshots` wires Roborazzi and the test libraries; feature build scripts carry no screenshot block. Shared failure texts (`format/Failures.kt`: `FAILURE_NETWORK`, `commonFailureText`), limits (`format/Limits.kt`: `BP_TOTAL`, `DISPLAY_NAME_MAX`, `DESCRIPTION_MAX`, `NOTES_MAX`, `FIRST_DATE`, `DAYS_AHEAD_MAX`) and `savedSplitLabel` live in `:core:design`. `KvittMotion` holds only the seesaw spring; `KvittIcon.Description` and unused dimens/shapes removed; catalog entries `roborazzi-junit-rule`, `roborazzi-compose`, `androidx-hilt-navigation-compose`, `androidx-lifecycle-viewmodel-compose` removed; one `CopyCategoryKeywords` task shared by `:data` and `:core:fakes`. 941 tests, no golden changed, release APK 10.46 MB. Landed by the Android overhaul (`e4e6e5d` to `c582363`). | Eight modules each had a copy of the same screenshot harness, and M12f had to fix the same capture bug in two of them; test fixtures rather than a `:core:testing` module let `:core:design`'s own tests use them, add no module to the §6.2 graph and are treated by lint as test sources. The failure-text map, the limits (`BP_TOTAL` in five copies) and the saved-split label were duplicated across features; the removed tokens, icon and catalog entries were never referenced. | SPEC §6.2 (`:core:design` row, Screenshot tests), §11.1, §11.3; `docs/ux/design-spec.md` §2.11 and the icon table; `android/README.md` | | D-119 | 2026-10-19 | APK download rate limit. E-6 `GET /apk/files/{file_name}` is limited to 10 requests per minute per client IP in its own fixed window (`APK_DOWNLOAD_LIMIT` in `src/api/rate_limit.rs`), separate from and not counted against the 60 per minute of the other unauthenticated endpoints; the client IP as in D-032 (peer, or the last `X-Forwarded-For` entry from the trusted proxy); refusals are 429 `rate_limited` with `Retry-After`; every request counts, a 404 included; in memory, pruned like the other windows. E-5 `GET /apk/latest` and E-4 `GET /i/{code}` stay on the 60 per minute. Landed `96e4cbd` (unit test and an integration test in `tests/core_api/rate_limit.rs`). Owner: "APK download limit assumed is OK". | Security review L-3: each E-6 answer can be a ~10 MB APK, so 60 per minute per IP let one client pull about 600 MB a minute from the Pi; E-5 and E-4 answers are small and the phone checks E-5 at most every 6 hours (AC-16.2). | SPEC §8.6, §10.6, §14.3.4 (E-6), §11.4 | +| D-120 | 2026-10-19 | TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (`infra/cert-sync/**`, `infra/pi/Caddyfile`, images, volumes `certs` and `caddy_data`, secrets `ha_ssh_key` and `ha_known_hosts`, `.env` keys `HA_PORT`, `HA_USER`, `HA_CERT_DIR`). (1) Kvitt gets its own public host name `KVITT_PUBLIC_HOST` (owner-chosen, recorded in HANDOFF; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects by name. `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`. A path prefix on another name is not supported. (2) The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues the name's Let's Encrypt certificate via HTTP-01 on port 80. (3) The Kvitt Pi runs `server` (publishes 8080 on all LAN interfaces, hardening unchanged) and `backup` (unchanged, D-105). NPM forwards plain HTTP over the LAN (accepted by the owner). (4) `KVITT_TRUSTED_PROXY=${HA_HOST}`, NPM's LAN IPv4 address, checked by `install.sh`; the server is unchanged and takes the last `X-Forwarded-For` entry from that peer. (5) The NPM proxy-host settings and the Custom Nginx Configuration of §10.5.2 carry over D-117's headers and log redaction: headers at server level with the server's copies hidden; its own `location /` without `add_header` (NPM's default adds `X-Served-By`, which makes nginx drop server-level headers); `location ^~ /i/` with `access_log off` and `error_log /dev/null crit` because NPM's access log writes `$request_uri` with the query string; `client_max_body_size 6m`; `proxy_read_timeout 60s`; HSTS on (NPM's 2 years). (6) Health checks: `http://localhost:8080/health` on the Pi, then through NPM (warning only); `install.sh` removes the old stack's leftovers. (7) Gitea login: the secret is interpolated in the `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" \| docker login …`), the owner's proven form, because the env-based form came through empty on act_runner 0.6.1 with a user-level secret; this reverses L-8's 'never expanded inside a shell line'; act_runner masks the value. Revises D-103 (bundle files), D-104 (images, Caddy pin, cert-sync packages, trigger path), D-106 (cert-sync part), D-117 parts 1, 2, 3, 7, 8, D-023's cert-sync secrets. Verified on the Mac 2026-10-19: nginx -t against NPM v2.16.0 templates; rehearsal server-behind-nginx 21 checks passed (headers once each, HSTS, http→https 301, 413 at 7 MB, rate limit per forwarded client, forged X-Forwarded-For contained, invite code/name/`/i/` absent from nginx logs including on 502). | Owner 2026-10-19: NPM already manages Let's Encrypt; the HA name is taken; 8443 → NPM 443 confirmed. | SPEC §8.5, §8.7, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.7, §10.10, §11.2, §14.7 | +| D-121 | 2026-10-19 | The Android app's public host is a build property. `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; never resolves, so a release built without it cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, `/i/`, `autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://:8443` (`link/ReleaseServer.kt`). `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` for S-1's prefill and its non-default-server check. Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (follow-up agent running). The pinned-root config's domain (D-082) is still the old literal and must follow the property if that fallback is ever prepared. Landed `d090c6c`. | D-120 gave Kvitt its own host name; it was hard-coded in three places. | SPEC AC-2.6, AC-2.7, §8.5, §10.5.3, §10.6, §13.3 S-1, §12 OQ-6; `android/README.md` | diff --git a/docs/validation/security-review.md b/docs/validation/security-review.md index 02fe955..11b1332 100644 --- a/docs/validation/security-review.md +++ b/docs/validation/security-review.md @@ -32,6 +32,8 @@ Location: `infra/cert-sync/sync.sh:36-42`; SPEC §10.5.1 ("variant B: added as a Issue: cert-sync holds `/secrets/ha_ssh_key`. With variant B there is no `restrict,command=` on the HA side, so whoever holds the key (a compromised cert-sync container, or the key copied around on the Mac and the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443 and the house's automation. The script's own fixed `tar` command does not restrict the server side. Smallest fix (infra + owner): make variant A the only accepted variant (`restrict,command="tar -C -cf - fullchain.pem privkey.pem"`, plus `from=""`), and if the add-on cannot do that, use a dedicated non-root user that can read `/ssl` only. Record the variant in `infra/README.md` as §10.5.1 already asks. +> **Note 2026-10-19 (D-120, SPEC v0.21):** moot. Caddy and cert-sync are removed; TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi, and no SSH key for the Home Assistant Pi exists in the stack any more (`install.sh` deletes `ha_ssh_key` and `ha_known_hosts` left on the Kvitt Pi). Closed. + **M-3. Picture decoding can exceed the server's memory limit (8000 × 8000 px vs 160 MiB).** Location: `server/crates/kvitt-server/src/api/pictures.rs:39` (`MAX_DIMENSION`), `:66-92` (`process`), `infra/pi/compose.yaml` (`mem_limit: 160m`); limit from SPEC AC-4.4. Issue: the dimension check happens before decoding (good), but an 8000 × 8000 image is allowed: decoding to RGBA is 256 MB, plus `to_rgba8`/`to_rgb8` copies, on a container limited to 160 MiB. A 5 MiB PNG or WebP easily encodes that size (a flat colour compresses to a few KB). Only an authenticated device can call `PUT /me/picture`, so in this threat model the attacker is a lost phone with a live token (until revoked) or a bug in the app, not a stranger; the effect is a crash and restart of the server (and of the sync for both phones) on each upload. @@ -44,11 +46,15 @@ Location: `infra/pi/Caddyfile:11-14`; SPEC §8.7 (the Rust server logs the route Issue: Caddy logs `request.uri` in full: `/i/?from=` and the `?from=` value, together with the client IP and User-Agent, into the json-file logs on the Pi (10 MB × 3). Codes live 72 h and are single-use, so the risk is low (an attacker with Pi log access is out of scope), but it is personal data and a secret in a log that §8.7 says must not hold either. Fix (infra): add `log_skip` (Caddy ≥ 2.8) for the `/i/*` matcher, or `log { format filter { request>uri delete } ... }` — one line in the Caddyfile; or drop the access log entirely (the server already logs every request). +> **Note 2026-10-19 (D-120, SPEC v0.21):** Caddy is gone; the redaction moved to NPM's Custom Nginx Configuration (SPEC §10.5.2): `location ^~ /i/` has `access_log off` and `error_log /dev/null crit`, so neither the code nor the `?from=` name reaches NPM's logs, a 502 included (rehearsed on the Mac). Other paths are logged with their query strings, which hold no personal content; NPM logs no request headers. + **L-2. The Caddy container is not hardened like the other three.** Location: `infra/pi/compose.yaml` (`caddy` service: no `user`, no `cap_drop`, no `read_only`); SPEC §8.9 hardens only the server. Issue: Caddy runs as root with every default capability and a writable root filesystem, and its admin API is bound to `0.0.0.0:2019` for every container on the `kvitt` network (only cert-sync needs it). This is the process that terminates TLS from the internet. Fix (infra): `cap_drop: [ALL]`, `cap_add: [NET_BIND_SERVICE]` (or none: 8443 is unprivileged), `read_only: true` with `tmpfs: [/tmp]` and `caddy_data`/a `caddy_config` volume writable; keep `no-new-privileges`. Admin on `0.0.0.0` is required for cert-sync; acceptable on the internal network. +> **Note 2026-10-19 (D-120, SPEC v0.21):** moot. There is no Caddy container; the TLS-terminating process is the owner's existing NPM install on the Home Assistant Pi, outside this repository. On the Kvitt Pi only `server` publishes a port (8080 on the LAN), with the hardening of SPEC §8.9. + **L-3. APK download is unauthenticated and 11 MB per request at 60 requests per minute per IP.** Location: `server/crates/kvitt-server/src/api/apk.rs:76-101`, `src/api/rate_limit.rs:56-63`; SPEC §8.6, §10.6. Issue: the only large unauthenticated response. One IP may pull about 660 MB/min, several IPs more, saturating the home uplink (and the Pi's SD card) for as long as they like. The file is public by design (the landing page links it). @@ -79,6 +85,8 @@ Location: `.gitea/workflows/build-images.yaml` ("Log in to Gitea Container Regis Issue: `${{ }}` is substituted into the script text before the shell runs it: a token containing a quote or `$` breaks the line (and could inject), and the value appears in the step's process arguments on the runner. The token also needs `write:repository` (for the tag push) on top of `write:package`. Fix (infra): pass it as `env: { REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }} }` and use `printf '%s' "$REGISTRY_TOKEN" | docker login …`; keep the scopes minimal (the tag push can use the checkout token if Gitea provides one). +> **Note 2026-10-19 (D-120, SPEC v0.21):** the fix of `e70fe0d` is reversed (`4f69ae0`). The env-based form came through empty on act_runner 0.6.1 with a user-level secret ("password is empty" on the first run), so the login step interpolates the secret in the `run` line again, as `printf '%s' "${{ secrets.kvitt_cicd }}" | docker login … --password-stdin` (the owner's proven form); act_runner masks the value in the log. The quoting risk remains for a token containing `"` or `$`; Gitea tokens are hex. The scope stays `write:package` only. + **L-9. Images are pulled by tag over plain HTTP from the LAN registry.** Location: `infra/pi/compose.yaml` (`${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`), `infra/pi/install.sh` (`insecure-registries`), `infra/pi/deploy.sh`; SPEC §10.3, D-001/D-104 (LAN is trusted). Issue: anyone on the home Wi-Fi who can answer for `gitea.lan` (or the Gitea Pi itself, if compromised) can hand the Kvitt Pi a tampered server image that sees every token hash and all data. Accepted by the spec's trust in the LAN; noted because the server is the one component that holds everything. @@ -101,6 +109,8 @@ Fix (server): return 404 in both cases, or leave as is. **I-5.** Client-IP correctness to verify after deploy: the rate limiter trusts the last `X-Forwarded-For` entry from Caddy (`rate_limit.rs:203-227`). If Docker's userland proxy ever fronts port 8443 (IPv6 without `ip6tables`, or `userland-proxy` quirks), Caddy sees the bridge gateway for every client and all internet clients share one 60/min bucket and one 10-failed-accepts lockout. Check once in Caddy's log that `request.remote_ip` is a public address. +> **Note 2026-10-19 (D-120, SPEC v0.21):** the trusted proxy is now NPM (`KVITT_TRUSTED_PROXY` = `HA_HOST`, its LAN IPv4 address) and the server publishes 8080 on the LAN; the one-off check is a `conntrack -L -p tcp --orig-port-dst 8080` on the Kvitt Pi showing `HA_HOST` as the source and no `172.*` address in the reply half (SPEC §10.10), instead of Caddy's `remote_ip`. + **I-6.** `bootstrap` and `pair-device` print the invite code to stdout (`src/cli/bootstrap.rs:41-62`). Through `docker compose run --rm` the line also sits in that one-off container's json log until the container is removed. Expected; the owner reads it and it expires in 72 h. **I-7.** The Ktor/OkHttp client keeps default redirect handling (`data/api/KvittApi.kt:246-248`). Neither the server nor Caddy redirects, and TLS excludes a third party, so the bearer cannot be bounced to another host. Fine. diff --git a/prompts/README.md b/prompts/README.md index 71205b8..79d779d 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -2,7 +2,7 @@ One ready-to-paste start prompt per plan (`PLAN-.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration). -Dependencies follow SPEC §11.1 and §11.2 (v0.20). Status as of 2026-10-19: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`06f48ef`), M2b landed (`c277ee3`), M3 landed (`7869ec4`), M5 landed (`8f0e156`), M4 landed (`15056dc`), M9 landed (`50339ae`); SPEC v0.7 landed (`86e08a1`); M12a landed (`aa765bb`); SPEC v0.8 landed (`d39eb2c`); M12c landed (`b09f322`); M12b landed (`6eaccec`); U1 and U2 landed (`6da6b1b`, owner approved); SPEC v0.9 landed (`e676812`); M12d landed (`557af7e`); M6 landed (`7730a31`); SPEC v0.10 landed (`9ee67d4`); M7a landed (`1ea752c`), M7e (`ea7d494`), M7d (`7380e50`), M7b (`0e1d99d`), M7c (`0b140aa`), M7f (`c4ab35f`); M6b `:core:design` fixes landed (`b24c8b5`); SPEC v0.11 landed (`f4dc10c`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`3dda414`, no prompt file; brief by the orchestrator); M8 landed (`cb832a7`, `3d9eea8`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`8c60715`, `b7b7483`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`51d6ff8`); the Vico removal (`a312758`), the FCM `fid` target (`f56dcbe`) and the Pi image arm64 check (`3e6c337`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`328c466`, D-097) with the `fid` follow-up (`6eb3f85`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`75b549f`, `a0c25b0`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`c30a49e`) and M8d (`bacbd18`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`bfc3acb`); the M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`), M8f (`956908a`, D-108, instant navigation) and the chips cleanup (`1328afe`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108; after v0.17 the history rewrite and first push to `origin` (`18c527f`, all later hashes in this file are the rewritten ones), the infra overhaul (`2fac1ec` to `e70fe0d`), the server gap pass (`8660b7f`, D-113), the infra security fixes (`7fb86f5`, D-117) and M12f (`dc7a296` to `a0259ab`, D-109 to D-112, D-116; no prompt file, orchestrator brief) landed; SPEC v0.18 records them with the AC-coverage audit and the security review (`docs/validation/`); the server overhaul (`8103dae` to `b985ce9`, D-114, D-115) landed too and is recorded in v0.18; the Android overhaul (`e4e6e5d` to `c582363`, D-118; no prompt file, orchestrator brief) landed and was pushed to `origin` at `c582363`; SPEC v0.19 records it (`3ef2c15`); the APK download rate limit (`96e4cbd`, D-119; no prompt file, orchestrator brief) landed and was pushed to `origin`; SPEC v0.20 records it with the owner's security-review answers. In flight: the NPM rework (D-120 reserved; no prompt file, orchestrator brief), which replaces Caddy and cert-sync with an Nginx Proxy Manager proxy host on the Home Assistant Pi and reshapes the M2c-pi bundle. What remains after it is the owner's M2c-pi run on the Kvitt Pi (deferred by the owner until he moves over), then M10 and M11. +Dependencies follow SPEC §11.1 and §11.2 (v0.21). Status as of 2026-10-19: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`06f48ef`), M2b landed (`c277ee3`), M3 landed (`7869ec4`), M5 landed (`8f0e156`), M4 landed (`15056dc`), M9 landed (`50339ae`); SPEC v0.7 landed (`86e08a1`); M12a landed (`aa765bb`); SPEC v0.8 landed (`d39eb2c`); M12c landed (`b09f322`); M12b landed (`6eaccec`); U1 and U2 landed (`6da6b1b`, owner approved); SPEC v0.9 landed (`e676812`); M12d landed (`557af7e`); M6 landed (`7730a31`); SPEC v0.10 landed (`9ee67d4`); M7a landed (`1ea752c`), M7e (`ea7d494`), M7d (`7380e50`), M7b (`0e1d99d`), M7c (`0b140aa`), M7f (`c4ab35f`); M6b `:core:design` fixes landed (`b24c8b5`); SPEC v0.11 landed (`f4dc10c`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`3dda414`, no prompt file; brief by the orchestrator); M8 landed (`cb832a7`, `3d9eea8`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`8c60715`, `b7b7483`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`51d6ff8`); the Vico removal (`a312758`), the FCM `fid` target (`f56dcbe`) and the Pi image arm64 check (`3e6c337`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`328c466`, D-097) with the `fid` follow-up (`6eb3f85`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`75b549f`, `a0c25b0`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`c30a49e`) and M8d (`bacbd18`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`bfc3acb`); the M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`), M8f (`956908a`, D-108, instant navigation) and the chips cleanup (`1328afe`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108; after v0.17 the history rewrite and first push to `origin` (`18c527f`, all later hashes in this file are the rewritten ones), the infra overhaul (`2fac1ec` to `e70fe0d`), the server gap pass (`8660b7f`, D-113), the infra security fixes (`7fb86f5`, D-117) and M12f (`dc7a296` to `a0259ab`, D-109 to D-112, D-116; no prompt file, orchestrator brief) landed; SPEC v0.18 records them with the AC-coverage audit and the security review (`docs/validation/`); the server overhaul (`8103dae` to `b985ce9`, D-114, D-115) landed too and is recorded in v0.18; the Android overhaul (`e4e6e5d` to `c582363`, D-118; no prompt file, orchestrator brief) landed and was pushed to `origin` at `c582363`; SPEC v0.19 records it (`3ef2c15`); the APK download rate limit (`96e4cbd`, D-119; no prompt file, orchestrator brief) landed and was pushed to `origin`; SPEC v0.20 records it with the owner's security-review answers; `cargo audit` joined the server gate (`f1abb0d`); the NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`, D-120; no prompt file, orchestrator brief) replaced Caddy and cert-sync with an Nginx Proxy Manager proxy host on the Home Assistant Pi and reshaped the M2c-pi bundle (`PLAN-M2c-pi.md` carries a superseded-in-part note); the Android host property (`d090c6c`, D-121; no prompt file, orchestrator brief) landed; SPEC v0.21 records them. In flight: the D-121 follow-ups (`:data`'s default server URL, `infra/publish-apk.sh` passing `-Pkvitt.publicHost`). What remains is the owner's M2c-pi run on the Kvitt Pi (deferred by the owner until he moves over), then M10 and M11. ## Dependency order and waves @@ -36,8 +36,8 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 | 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`cb832a7`, `3d9eea8`); on-phone test run 2026-10-18; M8b fixes `328c466`; M8c `75b549f`, `a0c25b0`, `7c453d8`; M8d `bacbd18`; M8e `a59abeb`; M8f `956908a`; chips cleanup `1328afe` (no prompt files; briefs by the orchestrator) | | 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`8c60715`, `b7b7483`) | | 4 | M12f Audit and security follow-ups (Android) | none (orchestrator brief) | M8f, AC-coverage audit, security review | done (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116) | -| 4 | Server gap pass; infra overhaul; infra security | none (orchestrator briefs) | M2c-pi bundle, security review | done (`8660b7f`, D-113; `2fac1ec` to `e70fe0d`; `7fb86f5`, D-117); server overhaul `8103dae` to `b985ce9` (D-114, D-115); Android overhaul `e4e6e5d` to `c582363` (D-118); APK download rate limit `96e4cbd` (D-119); NPM rework in flight (D-120) | -| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`2476667`, D-103 to D-106); reshaped by the NPM rework (D-120, in flight); the owner's run on the Pi remains, deferred by the owner | +| 4 | Server gap pass; infra overhaul; infra security | none (orchestrator briefs) | M2c-pi bundle, security review | done (`8660b7f`, D-113; `2fac1ec` to `e70fe0d`; `7fb86f5`, D-117); server overhaul `8103dae` to `b985ce9` (D-114, D-115); Android overhaul `e4e6e5d` to `c582363` (D-118); APK download rate limit `96e4cbd` (D-119); `cargo audit` `f1abb0d`; NPM rework `4f69ae0` to `9231a1d` (D-120); Android host property `d090c6c` (D-121) | +| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`2476667`, D-103 to D-106); reshaped by the NPM rework (`4f69ae0` to `9231a1d`, D-120; the plan is superseded where it names Caddy and cert-sync); the owner's run on the Pi remains, deferred by the owner | | 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none | | 7 | M11 Import history | `M11.md` | M9, M10 | none |