SPEC v0.24: D-125 self-contained Pi compose project; workflow results
Build Kvitt images / build (push) Successful in 5m45s
Build Kvitt images / build (push) Successful in 5m45s
D-125 (owner decision 2026-10-20, landed6e5981d): the Kvitt Pi runs Kvitt from /home/jacobhagstedt/kvitt, a self-contained compose project with relative bind mounts, both containers as the owner's uid/gid, the seven .env keys compose reads, no scripts and nothing under /opt; operations are plain docker compose commands. Revises D-103, D-105, D-117 point 5 and D-120 point 6. SPEC §10.2, §10.6, §10.8 to §10.10, §11.2 and §14.7 rewritten; KVITT_PI_HOST placeholders become <kvitt-pi-address>. §10.3 records the Gitea workflow's first results (run 69: v1.0.0, backup 5 min 20 s, server 2 h 42 min; v1.0.1 from the sparse-checkout push) and the sparse checkout of3820f9c. HANDOFF, prompts/README.md and notes in PLAN-M11.md and PLAN-M2c-pi.md follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# PLAN-M11: Import history
|
||||
|
||||
> **Path changed by D-125 (SPEC v0.24, 2026-10-20).** `/opt/kvitt/import` is now `~/kvitt/import` (`/home/jacobhagstedt/kvitt/import`), and every Pi command runs in `~/kvitt`; SPEC §10.8 "Production run" is current.
|
||||
|
||||
## 1. Goal
|
||||
Run the Splitwise importer once against the production database with the owner's two real exports, so six years of history appear on both phones. Prove it by comparing the imported per-account balances with Splitwise's own total rows and record the comparison, aggregates only, in `docs/validation/splitwise-import.md`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
> **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. **Also superseded by D-125 (SPEC v0.24, 2026-10-20):** the Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project with relative bind mounts, both containers as the owner's uid, no install, deploy or other scripts and nothing under `/opt/kvitt`; operations are plain `docker compose` commands (SPEC §10.2, §10.10).
|
||||
|
||||
Runs after M8 lands and before M10 (D-001, §11.1).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.23** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.24** (2026-10-20). 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.
|
||||
|
||||
@@ -586,7 +586,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM
|
||||
* 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), on the default HTTPS port 443 (D-124); the router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant; nothing is forwarded to the Kvitt Pi (§10.1, §10.5, D-120, D-124). 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). The pinned-root config is generated at build time (task `generatePinnedRootNetworkSecurityConfig`) from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost` (D-121), into the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082); the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (`038c603`).
|
||||
* 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 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.
|
||||
* The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (an IP literal in the Pi's `.env`, D-125), 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
|
||||
|
||||
@@ -617,9 +617,9 @@ Two trusted users. The server is on the public internet on port 443, through NPM
|
||||
|
||||
### 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 server container runs as a non-root uid/gid: on the Kvitt Pi the owner's (`KVITT_UID`/`KVITT_GID` in `.env`, 1000:1000, D-125), on the development stack and as the image's default `10001`; with a read-only root filesystem and a 16 MB tmpfs `/tmp`, `/data` as the only writable volume, its one secret file read-only, no Linux capabilities, `no-new-privileges`.
|
||||
* 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://<KVITT_PI_HOST>:8080` directly; such requests count against their own address (§8.6, D-120).
|
||||
* Only the server publishes a port (8080 on the Kvitt Pi's LAN; the router forwards only to NPM). The `backup` sidecar publishes no port and is reachable only on the compose default network. Anyone on the home LAN can reach `http://<kvitt-pi-address>: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.
|
||||
|
||||
---
|
||||
@@ -867,55 +867,53 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe
|
||||
|
||||
## 10. Deployment & ops
|
||||
|
||||
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.
|
||||
All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `~/kvitt/secrets/` (`/home/jacobhagstedt/kvitt/secrets/`) on the Pi (D-125); 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 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.
|
||||
**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 (on the Pi both run as the owner's uid, D-125). The Pi's env template is `infra/pi/.env.example` (D-103, D-125). 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, 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).
|
||||
**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained compose project in `infra/pi/` (D-125, revises D-103): `compose.yaml`, the env template `.env.example` and a `.gitignore`, with the owner's checklist in `infra/README.md` "Pi: what the owner does" (seven steps) and the restore runbook `infra/RESTORE.md`. The owner fills `infra/pi/.env` on the Mac, copies `compose.yaml`, `.env` and `secrets/` to `/home/jacobhagstedt/kvitt` (`~/kvitt`) on the Kvitt Pi and runs plain `docker compose` commands there; nothing on the Pi outside that folder is installed or changed, there is no install script and no script on the Pi at all, and Docker and the registry trust are prerequisites (D-125, owner decision 2026-10-20). Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the project on the Mac (`docker compose config` with the env template, `shellcheck` on the scripts under `infra/`, a Podman rehearsal). Where §10.2 to §10.10 name a command on the Pi, the owner runs it in `~/kvitt`. 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. History: the first bundle landed on 2026-10-18 (`2476667`, D-103 to D-106: install, deploy, bootstrap, backup and APK scripts under `/opt/kvitt`); 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 (no Caddyfile, no Caddy, no cert-sync); since D-125 (`6e5981d`, 2026-10-20) the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` and `lib.sh` are removed and their equivalents are the compose commands of §10.6, §10.9 and §10.10. Step 4 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; 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 `<KVITT_PI_HOST>: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`, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). 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 |
|
||||
| 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; a prerequisite, already installed, usable without sudo by the owner's user `jacobhagstedt`, uid and gid 1000, D-125), static LAN address. Kvitt runs from `/home/jacobhagstedt/kvitt` (D-125). LAN address `<kvitt-pi-address>`, known to the owner (OQ-3, D-100; no `.env` key since `200c99f`); also NPM's Forward Hostname / IP; no agent and no Mac script reaches 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 `<kvitt-pi-address>:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the Pi'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`, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). 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 (Home Assistant's name); the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen; currently `kvitt.suorra.duckdns.org`, HANDOFF) points at the same public address (a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`) | External TCP 443 → Home Assistant Pi 443 (NPM), already in place for Home Assistant, serves Kvitt too: no new rule (D-124; the owner confirmed the 443 forward, 2026-10-19); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (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 |
|
||||
| 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). Pushes use the Gitea secret `kvitt_cicd` (§10.3). The Kvitt packages are public, so the Pi pulls anonymously; were they private, the owner runs `docker login gitea.lan` once on the Pi with a `read:package` token (HUMAN, D-125). Agents never hold credentials (D-100) |
|
||||
|
||||
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/.env` (mode 0600, from `infra/pi/.env.example`) (D-103; was `infra/compose.yaml` and `infra/env.example`; no Caddyfile since D-120). Services:
|
||||
File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it with `.env` (from `infra/pi/.env.example`) and `secrets/` to `/home/jacobhagstedt/kvitt` (`~/kvitt`) on the Kvitt Pi, a self-contained compose project: every path in it is relative to that folder and nothing outside it is installed or changed (D-125, revises D-103; until then `install.sh` installed the stack to `/opt/kvitt`). The folder holds `compose.yaml`, `.env`, `secrets/` (`fcm-service-account.json`, `rclone.conf`) and the bind-mounted folders `data/` (SQLite, pictures, `data/apk/`), `backups/` and `import/`, which the owner creates before the first start (`mkdir -p data/apk backups import`), because a bind-mount source that Docker creates itself belongs to root and the containers cannot write to it. Services:
|
||||
|
||||
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|
||||
|---|---|---|---|---|
|
||||
| `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}` (port 443, D-124), `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 |
|
||||
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`, command `serve` | `./data:/data`; `./secrets/fcm-service-account.json` at `/secrets/fcm-service-account.json`, read-only, `create_host_path: false`; `./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 `TZ`, `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`; the other settings keep their §10.4 defaults | 160 MiB (budget: RSS under 100 MB in steady state) | `kvitt-server healthcheck`, the binary's own `GET http://localhost:8080/health` (no curl in the image); every 30 s, timeout 5 s, 3 retries, start period 20 s |
|
||||
| `backup` | `${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}` | `./data:/data`; `./backups:/backups`; `./secrets/rclone.conf` at `/config/rclone/rclone.conf`, read-only, `create_host_path: false`; environment `TZ`, `HOME=/tmp`, `RCLONE_REMOTE=gdrive:` (fixed in `compose.yaml`, the `[gdrive]` remote of `rclone.conf`), `RCLONE_FOLDER_ID=${RCLONE_FOLDER_ID}` | 96 MiB | `/healthcheck.sh`: last successful run marker newer than 26 hours, or no run yet since start; every 5 min, start period 30 s |
|
||||
|
||||
* 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 `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).
|
||||
* No named volumes: `data` and `backups` are the bind mounts `./data` and `./backups` (D-125; were the named volumes `data` and `backups`; `certs` and `caddy_data` were removed by D-120).
|
||||
* Both services: `user: "${KVITT_UID}:${KVITT_GID}"`, the owner's uid and gid (1000:1000 on the Pi); `read_only: true` with a 16 MB tmpfs `/tmp`; `cap_drop: [ALL]`; `no-new-privileges`; `restart: unless-stopped`; logging driver `json-file` with `max-size: 10m`, `max-file: 3`; `TZ=Europe/Stockholm`.
|
||||
* The compose default network (no network is declared; was the bridge network `kvitt`); only `server` publishes a port.
|
||||
* One uid for both containers (D-105, revised by D-125): `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns, so `backup` runs as the server's uid. On the Pi that uid is the owner's own, so every file in `data/` and `backups/` belongs to the owner, the two secret files are the owner's with mode 0600 (`chmod 700 secrets && chmod 600 secrets/*`), and a restore moves files with plain `mv` and `cp` (§10.9). The images need no particular uid: the server image's `USER 10001` is only its default, which compose's `user:` overrides (the binary needs no account and nothing writable outside `/data`), and supercronic runs as any uid; the owner's uid has no account inside either image, so `backup` gets `HOME=/tmp` for rclone's cache. rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a private copy in `/tmp` on every run (D-105).
|
||||
* Each container mounts only its own secret file, read-only (D-117): the server never sees the Drive token. `create_host_path: false` makes a missing secret file stop `docker compose up` with "bind source path does not exist" instead of Docker creating a root-owned directory in its place. These two are the only secret files on the Pi since D-120.
|
||||
* The development stack (`infra/dev/compose.yaml`) is unchanged by D-125: both containers there run as uid 10001 (§10 intro).
|
||||
|
||||
`.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.
|
||||
`.env` keys (exhaustive, D-125, revises D-103): exactly the seven keys `compose.yaml` reads, and nothing else reads `.env` on the Pi. `KVITT_REGISTRY` (`gitea.lan/jacobh2`); `KVITT_TAG` (the image tag of both services, `v1.0.N`; a deploy or a rollback changes it, §10.10); `KVITT_PUBLIC_HOST` (the Kvitt host name, owner-chosen, recorded in HANDOFF; `kvitt.suorra.duckdns.org` in the template); `HA_HOST` (NPM's LAN IPv4 address, an IP literal rather than an mDNS name such as `homeassistant.local`, which the container cannot resolve; the template's only `CHANGE_ME`); `KVITT_UID` and `KVITT_GID` (`id -u` and `id -g` of the owner on the Pi; 1000 in the template); `RCLONE_FOLDER_ID` (the Drive folder id of OQ-4, filled in the template). Compose refuses to start while one of them is unset (`${VAR:?…}`); nothing else checks the values, since there is no install script. Removed keys: `HA_PORT`, `HA_USER`, `HA_CERT_DIR` (D-120); the informational `KVITT_PI_HOST` and `KVITT_PI_SSH_USER`, read by nothing on the Pi (2026-10-20, `200c99f`, revises D-103; the owner's `scp` commands and NPM name `<kvitt-pi-address>` instead); `RCLONE_REMOTE` (fixed to `gdrive:` in `compose.yaml`); `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` (D-125: private packages mean a one-time `docker login gitea.lan` on the Pi). On the Mac, `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (§10.6).
|
||||
|
||||
### 10.3 Images, registry and build
|
||||
|
||||
* Image names (D-104, D-120): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`).
|
||||
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host` (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<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is 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.
|
||||
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server` and `infra/backup`, `sparse-checkout-cone-mode: true`), the two build contexts (neither Dockerfile copies anything outside its context), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `b4d9467`). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
|
||||
* 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:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
|
||||
* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `tzdata`, and `infra/backup/backup.sh` run by supercronic; the healthcheck is `infra/backup/healthcheck.sh`.
|
||||
* The backup sidecar schedules with supercronic instead of busybox `crond` (D-104): it runs as a non-root user, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (`30 3 * * *`).
|
||||
* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for the backup sidecar; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. The Caddy pin and the cert-sync packages left with D-120; Nginx Proxy Manager is the owner's existing install, and the block of §10.5.2 was written against NPM v2.16.0's templates. Docker Engine on the Kvitt Pi 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: 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 <tag> [--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 `<UTC time> <KVITT_TAG> <image reference> <digest>` 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).
|
||||
* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for the backup sidecar; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. The Caddy pin and the cert-sync packages left with D-120; Nginx Proxy Manager is the owner's existing install, and the block of §10.5.2 was written against NPM v2.16.0's templates. Docker Engine on the Kvitt Pi (a prerequisite, already installed, D-125) comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`.
|
||||
* No scripts on the Pi (D-125, revises D-103): `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` and `lib.sh` were removed in `6e5981d`. Their equivalents are plain `docker compose` commands the owner runs in `~/kvitt`: install = copy `compose.yaml`, `.env` and `secrets/` from the Mac, `mkdir -p data/apk backups import`, `docker compose pull && docker compose up -d`, `curl localhost:8080/health`; bootstrap = `docker compose exec server kvitt-server bootstrap`; partner invite = `docker compose exec server kvitt-server invite-partner`; one backup = `docker compose exec backup /backup.sh`; deploy and rollback §10.10; APK §10.6; restore §10.9. A changed `compose.yaml` or `.env` is copied again the same way. The checks `install.sh` made on `.env` are gone (compose refuses an unset key, §10.2); its `/opt/kvitt/deployed-digests` record is replaced by `docker image inspect` (§10.10).
|
||||
* The Pi's Docker daemon must trust `gitea.lan` as an insecure (plain HTTP) registry (`insecure-registries` in `/etc/docker/daemon.json`). This is a prerequisite the owner keeps (the Kvitt Pi already has it for another project); nothing of Kvitt edits the daemon or the system (D-125; `install.sh` added it with sudo, D-103). Were the packages private, the owner runs `docker login gitea.lan` once with a `read:package` token (D-125).
|
||||
|
||||
### 10.4 Server configuration and CLI
|
||||
|
||||
@@ -951,11 +949,11 @@ Files under `KVITT_DATA_DIR`:
|
||||
| `healthcheck` | Calls `http://127.0.0.1:<port of KVITT_BIND>/health` (8080 by default), exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) | 0 or 1 |
|
||||
| `migrate` | Run migrations only | 0 |
|
||||
|
||||
Run them on the Pi with `docker compose exec server kvitt-server <command>` (or `docker compose run --rm server <command>` when `server` is stopped). Outside Docker, from the workspace root `server/`: `cargo run -p kvitt-server -- <command>` (the `-p` is needed because the workspace has two binaries), with `KVITT_DATA_DIR` set (`server/README.md`).
|
||||
Run them on the Pi in `~/kvitt` with `docker compose exec server kvitt-server <command>` (or `docker compose run --rm server <command>` when `server` is stopped) (D-125). Outside Docker, from the workspace root `server/`: `cargo run -p kvitt-server -- <command>` (the `-p` is needed because the workspace has two binaries), with `KVITT_DATA_DIR` set (`server/README.md`).
|
||||
|
||||
### 10.5 TLS
|
||||
|
||||
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`), 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}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); 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.
|
||||
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`), 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}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); 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 4 (step 6 before D-125); no agent and no script touches NPM.
|
||||
|
||||
#### 10.5.1 Nginx Proxy Manager
|
||||
|
||||
@@ -965,7 +963,7 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
|
||||
|---|---|---|
|
||||
| Details | Domain Names | `<KVITT_PUBLIC_HOST>` (currently `kvitt.suorra.duckdns.org`, HANDOFF) |
|
||||
| Details | Scheme | `http` |
|
||||
| Details | Forward Hostname / IP | `<KVITT_PI_HOST>` (the Kvitt Pi's LAN IPv4 address) |
|
||||
| Details | Forward Hostname / IP | `<kvitt-pi-address>` (the Kvitt Pi's LAN IPv4 address) |
|
||||
| Details | Forward Port | `8080` |
|
||||
| Details | Cache Assets | off |
|
||||
| Details | Block Common Exploits | on |
|
||||
@@ -980,15 +978,15 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
|
||||
| SSL | Trust Upstream Forwarded Proto Headers | off |
|
||||
| Advanced | Custom Nginx Configuration | the block of §10.5.2, exactly, comments included |
|
||||
|
||||
* Plain HTTP on the LAN: NPM forwards plain HTTP over the home LAN to `<KVITT_PI_HOST>: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: <what the client sent>, <client address>`. 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).
|
||||
* Plain HTTP on the LAN: NPM forwards plain HTTP over the home LAN to `<kvitt-pi-address>: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: <what the client sent>, <client address>`. 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 in `.env`, because the container cannot resolve an mDNS name such as `homeassistant.local`; nothing checks it since D-125); 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 `<KVITT_PUBLIC_HOST>`, 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 `<KVITT_PUBLIC_HOST>` (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 <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/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).
|
||||
* Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs `curl -s localhost:8080/health` on the Kvitt Pi, and checks `/health` through NPM, from outside (`https://<KVITT_PUBLIC_HOST>/health` on the phone with Wi-Fi off) or from the Pi (`curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health`, with the certificate checked); `infra/README.md` "Pi: what the owner does" steps 3, 4 and 6.
|
||||
|
||||
#### 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):
|
||||
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 4 since D-125, unchanged):
|
||||
|
||||
```nginx
|
||||
# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
|
||||
@@ -1039,7 +1037,7 @@ location ^~ /i/ {
|
||||
#### 10.5.3 Fallback: NPM down
|
||||
|
||||
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over `https://<KVITT_PUBLIC_HOST>` (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://<KVITT_PI_HOST>: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.
|
||||
* On the home Wi-Fi only debug builds can reach the server directly, at `http://<kvitt-pi-address>: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`; the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, into the release resources only when `kvitt.tls.pinnedRoot=true`, a `domain-config` trusting only `@raw/kvitt_root_ca`; the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed, D-121, `038c603`) 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.
|
||||
|
||||
@@ -1053,8 +1051,8 @@ location ^~ /i/ {
|
||||
```
|
||||
|
||||
The file is git-ignored by the `*.jks` pattern and never committed (D-101, revises D-024's "outside the repository"). `android/local.properties` holds its absolute path and secrets in the keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias` (`kvitt`), `kvitt.release.keyPassword`. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys in `local.properties`, `:app:assembleRelease` builds an unsigned APK (M8). Release signing was verified on 2026-10-18: alias `kvitt`, RSA 4096, APK signature scheme v2.
|
||||
* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `c30a49e`); the signed APK is about 10 MB (10.0 MB at `a0259ab`; 43 MB before R8); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-<version_code>.mapping.txt` for retrace; mappings are never served and stay outside the `data` volume (D-103).
|
||||
* `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (read, not sourced; it refuses a missing file or key, a `CHANGE_ME` value, anything that is not a host name, a URL included, and the old name `icehack.asuscomm.com`, the checks of `install.sh`; landed `4b1ae0c`, D-122), builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode`, `-Pkvitt.versionName` and `-Pkvitt.publicHost=<KVITT_PUBLIC_HOST>`, prints the release server `https://<KVITT_PUBLIC_HOST>` (D-124) in its header and summary, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to `/opt/kvitt/apk/` on the Pi (`scp`) and runs `/opt/kvitt/install-apk.sh`, which checks each SHA-256 against `latest.json`, copies the APKs into the `data` volume as `apk/kvitt-<version_code>.apk` and writes `apk/latest.json` last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in `/opt/kvitt/apk/`. `latest.json`:
|
||||
* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `c30a49e`); the signed APK is about 10 MB (10.0 MB at `a0259ab`; 43 MB before R8); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-<version_code>.mapping.txt` for retrace; mappings are never served and stay on the Mac, outside the Pi's `data/` (D-103, D-125).
|
||||
* `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (read, not sourced; it refuses a missing file or key, a `CHANGE_ME` value, anything that is not a host name, a URL included, and the old name `icehack.asuscomm.com`; landed `4b1ae0c`, D-122), builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode`, `-Pkvitt.versionName` and `-Pkvitt.publicHost=<KVITT_PUBLIC_HOST>`, prints the release server `https://<KVITT_PUBLIC_HOST>` (D-124) in its header and summary, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner then copies, from the Mac, the APK first and `latest.json` last straight into the server's `data/apk/` on the Pi (`scp infra/pi/out/apk/kvitt-<version_code>.apk jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/`, then the same for `infra/pi/out/apk/latest.json`), so the server, which reads `latest.json` on every request, never announces a file it does not have; `curl -s http://<kvitt-pi-address>:8080/apk/latest` then shows the new `version_code` (D-125; `install-apk.sh`, with its SHA-256 check against `latest.json` and its copy into the named volume, is removed). The mapping files stay on the Mac. `latest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1070,7 +1068,7 @@ location ^~ /i/ {
|
||||
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, no port, so it matches every port of the host, path prefix `/i/`, `android:autoVerify="false"`, D-124) and as `RELEASE_SERVER_URL` = `releaseServerUrl(host)` = `https://<host>` (`link/ReleaseServer.kt`, `releaseServerUrl` in `:feature:onboarding`'s `ServerAddress.kt`; `RELEASE_SERVER_PORT` removed, D-124); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (`:443`, `:80`) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`.
|
||||
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
|
||||
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`).
|
||||
* Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103).
|
||||
* Older APKs stay in `data/apk/` on the Pi until the owner removes them by hand (D-125; `install-apk.sh` kept the newest 5, D-103); `infra/pi/out/apk/` on the Mac keeps the newest 5.
|
||||
|
||||
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
|
||||
|
||||
@@ -1082,12 +1080,12 @@ No new command-line tool is installed for this (D-022): `gcloud` exists on the M
|
||||
4. Create two Android apps (`projects.androidApps.create`): display name "Kvitt", package `se.hagstedt.kvitt`; display name "Kvitt debug", package `se.hagstedt.kvitt.debug`.
|
||||
5. Fetch each app's config (`projects.androidApps.getConfig`) and merge them into one `google-services.json` holding both clients, written to `infra/secrets/google-services.json` in the main checkout (git-ignored). M8 copies it into `android/app/google-services.json` (git-ignored) with `infra/dev/copy-google-services.sh <main checkout>`, written by M2c-dev.
|
||||
6. `gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"`; grant `roles/firebasecloudmessaging.admin` on the project; `gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com`.
|
||||
7. Copy the key to `/opt/kvitt/secrets/fcm-service-account.json` on the Pi (mode 0440, root:10001). Done by `infra/pi/install.sh`, run by the owner (D-103).
|
||||
7. Copy the key to `~/kvitt/secrets/fcm-service-account.json` on the Pi, owned by the owner, mode 0600 (D-125; was `/opt/kvitt/secrets/`, mode 0440, root:10001, installed by `install.sh`, D-103). Done by the owner (`infra/README.md` "Pi: what the owner does", steps 2 and 3).
|
||||
8. If an organisation policy blocks service-account key creation, stop and report; do not change org policies.
|
||||
|
||||
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 5, `install.sh`, since D-120).
|
||||
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 install on the Kvitt Pi (checklist steps 2 and 3, D-125).
|
||||
|
||||
The FCM free tier is sufficient; no billing account is required.
|
||||
|
||||
@@ -1142,43 +1140,45 @@ kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name
|
||||
|
||||
Production run (M11, D-025), in this order:
|
||||
|
||||
1. Take a backup first: `docker compose exec backup /backup.sh`, and confirm both files on Drive (`rclone lsf`).
|
||||
1. On the Pi in `~/kvitt` (D-125), take a backup first: `docker compose exec backup /backup.sh` (it ends with `backup: ok <stamp>` once both files are confirmed on Drive with `rclone lsf`).
|
||||
2. Resolve the two production user ids by display name from the database (a read-only `SELECT id, role, display_name FROM users` through the `backup` container's `sqlite3`); the owner is the `admin` user. The ids go into the `--user` arguments (an exact, unique display name is also accepted, D-046).
|
||||
3. Copy the CSV files from the Mac to the Pi into `/opt/kvitt/import/`, which is bind-mounted read-only into `server` as `/import` (§10.2).
|
||||
3. Copy the CSV files from the Mac to the Pi into `~/kvitt/import/` (`/home/jacobhagstedt/kvitt/import/`; `scp <file> jacobhagstedt@<kvitt-pi-address>:kvitt/import/`), which is bind-mounted read-only into `server` as `/import` (§10.2, D-125; was `/opt/kvitt/import/`).
|
||||
4. `docker compose stop server`; for each file run `docker compose run --rm --entrypoint kvitt-import server --db /data/kvitt.sqlite --file /import/<file> --account "<name>" --user "…" --user "…"`, first with `--dry-run`, then without; each must exit `0` with the reconciliation `match` (D-045); the target accounts must not exist yet or be empty; `docker compose start server`; check `/health`. (`--entrypoint` is needed because the image's entrypoint is `kvitt-server`, §10.3.)
|
||||
5. Delete the CSV files from `/opt/kvitt/import/`.
|
||||
5. Delete the CSV files from `~/kvitt/import/`.
|
||||
|
||||
### 10.9 Backups and restore
|
||||
|
||||
Backup (`infra/backup/backup.sh`, daily 03:30 Europe/Stockholm, also runnable as `docker compose exec backup /backup.sh`, or on the Pi as `/opt/kvitt/backup-now.sh`, which waits for `last-success` to change):
|
||||
Backup (`infra/backup/backup.sh`, daily 03:30 Europe/Stockholm, also runnable on the Pi in `~/kvitt` as `docker compose exec backup /backup.sh`, which ends with `backup: ok <stamp>` when the run succeeded; `backup-now.sh` is removed, D-125):
|
||||
|
||||
1. `sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite"` (online, consistent).
|
||||
2. `sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check"` must print `ok`, else abort with a log line.
|
||||
3. `gzip -9` to `/backups/kvitt-<UTC yyyyMMddTHHmmssZ>.sqlite.gz`; `tar -czf /backups/pictures-<same stamp>.tar.gz -C /data pictures`.
|
||||
4. For every pending `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` in `/backups` (this run's pair and any leftovers of an earlier failed run): `rclone copy <file> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable` (copy only; never `sync`, never delete, never overwrite), then confirm it by name and size with `rclone lsf` (D-106). rclone uses a temporary copy of the read-only `rclone.conf` (D-105).
|
||||
5. Only after every file is confirmed are the local copies deleted from the Pi's `backups` volume (D-002), and only then is `/backups/last-success` written with the stamp (D-106). A failed upload or confirmation keeps the local files and leaves `last-success` unchanged; the next good run uploads the leftovers. Nothing on Drive is ever deleted. When `RCLONE_FOLDER_ID` is empty (the development stack, D-001), step 4 is skipped with a log line, the local files are kept and `last-success` is written.
|
||||
5. Only after every file is confirmed are the local copies deleted from the Pi's `backups/` folder (D-002), and only then is `/backups/last-success` written with the stamp (D-106). A failed upload or confirmation keeps the local files and leaves `last-success` unchanged; the next good run uploads the leftovers. Nothing on Drive is ever deleted. When `RCLONE_FOLDER_ID` is empty (the development stack, D-001), step 4 is skipped with a log line, the local files are kept and `last-success` is written.
|
||||
|
||||
rclone setup (HUMAN, once, done 2026-10-18): the owner ran `rclone authorize "drive"` and signed in; the token (`infra/secrets/rclone-drive-token.json`) became `infra/secrets/rclone.conf` with a `[gdrive]` remote (`type = drive`, `scope = drive`, `token`), both git-ignored. The owner stages it with the bundle and `install.sh` installs it on the Pi (§10.2); the folder id stays in `.env` as `RCLONE_FOLDER_ID`, not in the conf. The Drive destination root folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (OQ-4, answered); it is the value of `RCLONE_FOLDER_ID` in the Pi's `.env`.
|
||||
rclone setup (HUMAN, once, done 2026-10-18): the owner ran `rclone authorize "drive"` and signed in; the token (`infra/secrets/rclone-drive-token.json`) became `infra/secrets/rclone.conf` with a `[gdrive]` remote (`type = drive`, `scope = drive`, `token`), both git-ignored. The owner copies it to `~/kvitt/secrets/rclone.conf` on the Pi, mode 0600 (§10.2, D-125); the folder id stays in `.env` as `RCLONE_FOLDER_ID`, not in the conf. The Drive destination root folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (OQ-4, answered); it is the value of `RCLONE_FOLDER_ID` in the Pi's `.env`.
|
||||
|
||||
Restore (documented step by step in `infra/RESTORE.md`; its commands run through the `backup` container, which has `sqlite3`, `rclone`, the Drive credentials and uid 10001):
|
||||
Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the owner, D-125). The files in `data/` and `backups/` are the owner's (both containers run as the owner's uid), so they are moved on the host with plain `mv`, `cp`, `gunzip` and `tar`; only `rclone` (listing and downloading from Drive, on a temporary copy of `rclone.conf`) and `sqlite3` (`PRAGMA integrity_check`) run in the running `backup` container. Nothing is deleted. Not around 03:30, when the nightly backup runs.
|
||||
|
||||
1. `docker compose stop server`.
|
||||
2. Download the chosen `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` from Drive (rclone or browser) to the Pi.
|
||||
3. `gunzip`; `sqlite3 <file> "PRAGMA integrity_check"` must print `ok`.
|
||||
4. Move the current `/data/kvitt.sqlite*` aside (never delete), place the restored file as `/data/kvitt.sqlite`, extract pictures into `/data/pictures`.
|
||||
2. Download the chosen `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` from Drive into `backups/restore/` (rclone in the `backup` container, or the browser and `scp` from the Mac).
|
||||
3. `gunzip` on the host; `docker compose exec -T backup sqlite3 /backups/restore/<file> "PRAGMA integrity_check"` must print `ok`.
|
||||
4. Move the current `data/kvitt.sqlite`, `-wal`, `-shm` and `data/pictures` aside into `data/pre-restore-<UTC stamp>/` (never delete), copy the restored file to `data/kvitt.sqlite`, extract the pictures into `data/`.
|
||||
5. `docker compose run --rm server rotate-epoch`.
|
||||
6. `docker compose start server`; check `/health`.
|
||||
6. `docker compose start server`; `docker compose ps server` healthy; `curl -fsS http://localhost:8080/health`.
|
||||
7. Open the app on both phones; they detect the new epoch and re-push their data (§14.4.8). Check the balances match on both phones.
|
||||
8. Afterwards, `docker compose exec backup /backup.sh` once puts the restored state on Drive.
|
||||
|
||||
### 10.10 Operations
|
||||
|
||||
* Logs: `docker compose logs <service>` 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 <tag>` 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.
|
||||
* Where: every command on the Pi in this section is run by the owner in `~/kvitt` (`/home/jacobhagstedt/kvitt`) with plain `docker compose` (D-125, revises D-103's scripts); the owner's operations table is in `infra/README.md` "Pi: what the owner does", Operations. Agents prepare and check the project on the Mac and never connect to the Pi (D-100).
|
||||
* Status and logs: `docker compose ps`; `docker compose logs --tail 100 <service>`; NPM's own log files are inside NPM on the Home Assistant Pi (§10.5.2).
|
||||
* Disk: `df -h ~` and `du -sh ~/kvitt/*`. SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), older ones removed by hand.
|
||||
* Deploy (upgrade), after the Gitea workflow pushed a `v1.0.N` tag (or `infra/build-images.sh` printed one): `docker compose exec backup /backup.sh` first, continuing only after `backup: ok`, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then set `KVITT_TAG` in `.env` (`sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env`, noting the previous tag), `docker compose pull && docker compose up -d`, and check `docker compose ps` and `curl -s localhost:8080/health`. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop.
|
||||
* Rollback: set the previous `KVITT_TAG` in `.env` and `docker compose pull && docker compose up -d` (no `previous-tag` file and no `--skip-backup` since D-125). Migrations are forward-only, so if a migration had partially applied, restore the pre-deploy backup (§10.9, `infra/RESTORE.md`); sqlx runs each migration in a transaction, so this should not happen.
|
||||
* Bootstrap and invites: `docker compose exec server kvitt-server bootstrap` (first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invite `docker compose exec server kvitt-server invite-partner`; an extra device `docker compose exec server kvitt-server pair-device --user <user_id>` (§10.4).
|
||||
* 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.
|
||||
* Image digests (D-117 point 5, revised by D-125): the Pi keeps no digest record. After a pull, `docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:<tag>` (and `kvitt-backup`) prints the `sha256:` digest, which the owner compares with the workflow's "Print image digests" step for that run (§10.3); the registry is plain HTTP on the LAN (security review L-9), so a mismatch is investigated before the stack is trusted.
|
||||
* Client IPs (once, after the NPM proxy host and the router check, D-117, D-120, D-124): with the phone off Wi-Fi, open `https://<KVITT_PUBLIC_HOST>/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=<HA_HOST>` 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 <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/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).
|
||||
|
||||
@@ -1251,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, 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; port 443 (D-124: `4852803` bundle) landed 2026-10-19; the NPM proxy host for `kvitt.suorra.duckdns.org` is Online with its Let's Encrypt certificate (owner, 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 check (external 443 → Home Assistant Pi 443, already in place, no new rule, D-124), 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://<KVITT_PUBLIC_HOST>/health` answers from the phone on mobile data without a certificate warning; 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 nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, `.env` and secrets, copy, `install.sh`, the NPM proxy host, the router check (external 443 → Home Assistant Pi 443, no new rule, D-124), 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` (on 2026-10-19 not found under that name on the `jacobh2` account; the owner is fixing the name, HANDOFF). Done 2026-10-19: the Kvitt host name `kvitt.suorra.duckdns.org` and its NPM proxy host. 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) |
|
||||
| M2c-pi | Infra on the Pi (D-001, D-100, D-120, D-125) | 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; port 443 (D-124: `4852803` bundle) landed 2026-10-19; the sparse-checkout workflow (`3820f9c`, 2026-10-19), the `.env` without the informational keys (`200c99f`, 2026-10-20) and the self-contained compose project (D-125, `6e5981d`, 2026-10-20) landed. The NPM proxy host for `kvitt.suorra.duckdns.org` is Online with its Let's Encrypt certificate (owner, 2026-10-19); the Gitea workflow built and pushed `v1.0.0` (run 69) and `v1.0.1` (§10.3); the owner is installing on the Kvitt Pi (2026-10-20). Deliverable: a self-contained compose project in `infra/pi/` (`compose.yaml`, `.env.example`, `.gitignore`) that the owner copies to `/home/jacobhagstedt/kvitt` on the Kvitt Pi and runs with plain `docker compose` commands (D-100, D-125): `server` and `backup` as the owner's uid, the seven `.env` keys, relative bind mounts `./data`, `./backups`, `./import` (§10.2); a README checklist for the owner (`infra/README.md` "Pi: what the owner does": prerequisites, `.env` and secrets on the Mac and the copy, start, the NPM proxy host and its Custom Nginx Configuration of §10.5, the router check (external 443 → Home Assistant Pi 443, no new rule, D-124), from outside, 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/` | 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 D-125 (2026-10-20): compose config valid; a Podman rehearsal of the project folder as uid 501 (not 10001): both containers healthy with read-only roots, `/health` 200 with `db_ok: true`, the database files and `data/apk` owned by 501 on the host, `kvitt-server bootstrap` through `exec`, an APK and `latest.json` copied into `data/apk/` served at once, `/backup.sh` in three modes (no folder id: files kept, `last-success` written; a local remote: uploaded, confirmed, local copies deleted; a failing remote: files kept, `last-success` unchanged), the `RESTORE.md` steps ending with `/health` 200. Still valid from the NPM rework: `nginx -t` of the §10.5.2 block against NPM v2.16.0's templates and the 21 checks of the server behind nginx (§10.5.2). Not verifiable from the Mac: NPM's UI and Let's Encrypt for the Kvitt host name, the router forward, pulls from `gitea.lan`, Docker Compose itself (`create_host_path: false`, which Podman ignores), Drive. On the Pi, run by the owner from the checklist: `docker compose ps` shows both services healthy and `curl localhost:8080/health` answers ok; `https://<KVITT_PUBLIC_HOST>/health` answers from the phone on mobile data without a certificate warning; one backup pair appears in the Drive folder | Fill `infra/pi/.env` on the Mac (`HA_HOST`; check `KVITT_UID`/`KVITT_GID` and `KVITT_TAG`), copy `compose.yaml`, `.env` and `secrets/` to `~/kvitt` on the Kvitt Pi and run the seven steps of `infra/README.md` "Pi: what the owner does" (prerequisites, Mac, start, NPM, router check, from outside with first backup and bootstrap, deploy), answering yes/no per check. Done: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd` (renamed by the owner 2026-10-19; run 69 succeeded), the Kvitt host name `kvitt.suorra.duckdns.org` and its NPM proxy host (2026-10-19), Docker and the registry trust on the Pi (prerequisites). 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 |
|
||||
@@ -1272,7 +1272,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| M12e | Contract pass | Landed `8c60715` and `b7b7483` 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 `invite_kind` and `Pairing` (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, `devices.app_version_name` by migration 0003, server schema 3 (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092); `SettleState.untouched` (D-093); the D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new `s1_pairing_link_admin` and `s22_about_check_failed` | Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) | none (the development server must be rebuilt before the new APK is installed, §14.8) | M8, M7g, SPEC v0.12 |
|
||||
| M12f | Audit and security follow-ups (Android) | Landed `dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab` 2026-10-18 (D-109 to D-112, D-116; no prompt file, orchestrator brief). S-16 read-only lost version (`Rejection.lost`, D-109); S-22 versions from `/health` (`AboutState.schemaVersion`, D-110); `SettlementRepository` without throws (D-111); E-17 retry (D-112); invite links dropped while paired (AC-2.6); private notifications (AC-13.9, D-116); `localeFilters` en, sv (AC-19.1); `:data` tests for the 429 retry, D-040 refusals and WorkManager unique work (`work-testing`, §6.2); the `:feature:home` and `:feature:account` screenshot harness fix (§6.2); the 19 string keys R8 found unused removed (`strings.csv` 562 rows, §13.4) | Android gate (green at landing, 941 tests); release APK 10.0 MB | Look at S-16 and S-22 on the next install (HANDOFF §3) | M8f, the AC-coverage audit and the security review (§11.4) |
|
||||
| M9 | Splitwise importer | Landed `50339ae` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
|
||||
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps from the M2c-pi bundle (D-100); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 |
|
||||
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps of the M2c-pi compose project (D-100, D-125); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 |
|
||||
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
|
||||
|
||||
### 11.3 Notes per milestone
|
||||
@@ -1292,7 +1292,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
* **M4** (landed `15056dc`) added `into_make_service_with_connect_info::<SocketAddr>()` 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 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.
|
||||
* **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. D-125 (`6e5981d`, 2026-10-20) made the bundle a self-contained compose project in `/home/jacobhagstedt/kvitt` with no scripts and nothing outside that folder (§10.2, §10.10); the plan's note names it too. The remaining part is HUMAN, the owner installing the project on the Kvitt Pi (in progress 2026-10-20), 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.
|
||||
@@ -1300,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; 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.
|
||||
* **Infra overhaul** (landed `2fac1ec` to `e70fe0d`, no decision): one Gradle lock helper `tools/gradle-lock.sh` for `tools/gradle-serial.sh`, `tools/clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs the Android tests through `tools/gradle-serial.sh` with JDK 25 on `PATH`, `JAVA_HOME` untouched; `install.sh` keeps the deployed `KVITT_TAG` (moot since D-125: no `install.sh`); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3; reversed by D-120 point 7); `RCLONE_REMOTE` dropped from the development env (§10 intro). **Infra security** (landed `7fb86f5`, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10; dropped by D-125 for `docker image inspect`), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). D-120 carries the log redaction and the headers over to NPM's Custom Nginx Configuration (§10.5.2) and removes the Caddy container, the digest of its image and the cert-sync key.
|
||||
* **Overhaul outcomes** (2026-10-18). Server: landed through `b985ce9` (see the server overhaul above). Infra: landed through `e70fe0d` (see the infra overhaul above). **Android overhaul** (landed `e4e6e5d` to `c582363`, D-118, no prompt file, orchestrator brief): one screenshot and test harness in `:core:design`'s test fixtures and the convention plugin `kvitt.android.screenshots` (§6.2); the shared failure texts, limits and `savedSplitLabel` in `:core:design` (§6.2); dead code removed (`KvittMotion` down to the seesaw spring, `KvittIcon.Description`, unused dimens and shapes, catalogue and fake samples); the catalog entries `roborazzi-junit-rule`, `roborazzi-compose`, `androidx-hilt-navigation-compose` and `androidx-lifecycle-viewmodel-compose` removed; one `CopyCategoryKeywords` task in `build-logic` shared by `:data` and `:core:fakes` (D-055 holds); one `configureAndroidModule` for the application and library plugins; S-7's state types in `ExpenseEditorState.kt`. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed to `origin` at `c582363`.
|
||||
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
|
||||
|
||||
@@ -1321,7 +1321,7 @@ 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, 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-3 | Answered by placeholders (D-100, revised by D-120): the LAN address of the Kvitt Pi (`<kvitt-pi-address>`; its `.env` key `KVITT_PI_HOST` was dropped in `200c99f`) and the LAN IPv4 address of the Home Assistant Pi, where NPM runs (`HA_HOST`); with D-120 also the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF). ~~SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there; which SSH add-on is used~~ (cert-sync removed by D-120). The owner fills `HA_HOST` in the Pi's `.env` (D-125) and uses the Kvitt Pi's address in NPM and in `scp`; no agent needs them. | M2c-pi | answered |
|
||||
| OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered |
|
||||
| OQ-5 | Answered 2026-10-18 and implemented (D-099, `a0c25b0`): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. | M10 | answered |
|
||||
| OQ-6 | Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. | M10 | Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116) |
|
||||
@@ -2773,7 +2773,7 @@ android/
|
||||
app/
|
||||
infra/
|
||||
build-images.sh publish-apk.sh README.md RESTORE.md .shellcheckrc
|
||||
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)
|
||||
pi/{compose.yaml, .env.example, .gitignore} pi/.env, pi/secrets/, pi/out/ (git-ignored) (copied to /home/jacobhagstedt/kvitt on the Kvitt Pi, D-125)
|
||||
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)
|
||||
backup/{Dockerfile, backup.sh, healthcheck.sh} secrets/ (git-ignored)
|
||||
```
|
||||
@@ -2869,3 +2869,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 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://<host>: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`). |
|
||||
| 0.22 | 2026-10-19 | The D-121 follow-ups (`4a4e45f`, `4b1ae0c`), the server's host defaults (`c9abd24`, `e381e37`) and the `install.sh` step number (`ac17b41`, no decision). D-122 (`4a4e45f`, `4b1ae0c`; extends D-121): `AppBuildInfo` (defined in `:data`'s `DataModule.kt`, provided by `:app`'s `AppBuildInfoModule`) gains `releaseServerUrl` = `RELEASE_SERVER_URL`; S-22 shows the paired server, before pairing `AppBuildInfo.releaseServerUrl`; `DataModule.DEFAULT_SERVER_URL` removed; fakes and tests on `https://kvitt.example.org:8443` (30 S-21/S-22 goldens re-recorded); `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` and passes `-Pkvitt.publicHost` (§6.2, §10.6, §13.3 S-22, §14.2.4). D-123 (`c9abd24`, `e381e37`; revises D-120's leftovers): `KVITT_PUBLIC_URL` has no default and every `kvitt-server` command refuses to start without it; `KVITT_TRUSTED_PROXY` defaults to `none`, never looked up; the OpenAPI server is `https://{host}:8443` with variable `host` (default `kvitt.example.invalid`), representational for §14.8; E-7 and E-12 examples on `kvitt.example.invalid`; `tools/contracts-check.py` expects the `{host}` URL; 276 server tests (§10.4, §11.3, §14.3, E-7, E-12). The owner's Kvitt host name `kvitt.suorra.duckdns.org` (2026-10-19, recorded in HANDOFF; §10.1, §10.5, §10.5.1). |
|
||||
| 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). |
|
||||
| 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `<KVITT_PI_HOST>` is now `<kvitt-pi-address>` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). |
|
||||
|
||||
+35
-28
@@ -3,12 +3,18 @@
|
||||
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
|
||||
|
||||
## 1. Where things stand
|
||||
- `main` at `ad35231` (portless Android fixtures after D-124; on top of D-124's `a86ad24` server and contracts, `4852803` bundle, `038c603` Android with the pinned-root template, SPEC v0.22 `cce3e4e` and `ac17b41`) plus the SPEC v0.23 docs commit from `agent-docs-v023` once it lands. `origin/main` (Gitea) at `038c603`: `ad35231` is not pushed yet.
|
||||
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`), both git-ignored. SPEC §10.1 and §10.5.1 point here.
|
||||
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). From the Mac, `https://kvitt.suorra.duckdns.org/health` presents a valid certificate and returns NPM's 502, as expected while the Kvitt Pi is not running. The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
|
||||
- **Gitea secret diagnosis** (the temporary "Diagnose secrets" step, `dd07ca4`): the user-level secret `babymonitor_cicd` reaches the repository (length 40), `kvitt_cicd` has length 0, so the secret is not stored under that name on the `jacobh2` account. The owner is fixing the name (§3 item 1).
|
||||
- `main` at `28fa71b` (the compose deploy hint in the workflow and `infra/build-images.sh`, after `6e5981d`, the self-contained Pi compose project, D-125); `origin/main` (Gitea) at `6e5981d`, so `28fa71b` is not pushed yet; plus the SPEC v0.24 docs commit from `agent-docs-v024b` once it lands. Landed since SPEC v0.23 (`b1f4c10`): `3820f9c`, `200c99f`, `6e5981d`, `28fa71b` (below).
|
||||
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
|
||||
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
|
||||
- **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3.
|
||||
- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust are already on the Pi; nothing outside that folder is installed or changed. The owner is installing now (2026-10-20, §3 item 1).
|
||||
- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`.
|
||||
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures.
|
||||
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`.
|
||||
- Landed since SPEC v0.23 (recorded in v0.24):
|
||||
- Sparse-checkout workflow (`3820f9c`, no decision): cone-mode `sparse-checkout` of `server` and `infra/backup` (the two build contexts), so the action fetches with `--filter=blob:none` and no PNG is checked out; `fetch-depth: 0` stays because `fetch-tags` with a shallow fetch only brings tags on the tip; the temporary "Diagnose secrets" step removed. Its push built `v1.0.1`.
|
||||
- `.env` without the informational keys (`200c99f`, no decision; revises D-103): `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped, read by nothing on the Pi; the README's `scp`, `ssh` and NPM steps name `<kvitt-pi-address>`.
|
||||
- Self-contained Pi compose project (`6e5981d`, D-125, owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'): `/home/jacobhagstedt/kvitt` holds `compose.yaml`, `.env`, `secrets/` and the bind-mounted `data/`, `backups/`, `import/`; both containers as `KVITT_UID`/`KVITT_GID` (1000 on the Pi), read-only root, 16 MB tmpfs `/tmp`, each mounting only its own secret file (`create_host_path: false`); `.env` exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, operations are plain `docker compose` commands (`infra/README.md` "Pi: what the owner does", seven steps, and `infra/RESTORE.md`); no digest record (`docker image inspect` instead); APKs by `scp` into `data/apk/`. Rehearsed with Podman as uid 501: healthy, `/health` 200, bootstrap, APK served, backup in three modes, restore steps.
|
||||
- Compose deploy hint (`28fa71b`, no decision): the workflow's header and digest-step comment and `infra/build-images.sh`'s closing line name `cd ~/kvitt`, `KVITT_TAG` in `.env`, `docker compose pull && docker compose up -d` and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`.
|
||||
- Landed since SPEC v0.22 (recorded in v0.23):
|
||||
- Port 443 (D-124, owner decision 2026-10-19): Kvitt is served at `https://<host>` with no explicit port, because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts (`a86ad24`): OpenAPI server `https://{host}`, E-7 and E-12 examples without a port, `tools/contracts-check.py` follows. Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`, health checks through NPM on 443, README step 7 "Router: no new rule", the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android (`038c603`): `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed), the AC-2.6 intent filter without a port (matches every port, so an old `:8443` link still opens S-1 with the server it names), invite-link parsing drops `:443` and `:80`, 28 S-1 goldens on `https://kvitt.example.org`.
|
||||
- Portless Android fixtures (`ad35231`, D-124): `android/README.md`, the `AppBuildInfo` and `AboutRepositoryImpl` comments, the S-21 and S-22 fake scenarios and the `:data` test fixtures without `:8443`; 30 goldens re-recorded (6 S-21 invite-created, 24 S-22). What is left of `8443` in `android/` is deliberate: tests that an old `:8443` link still opens S-1 and that a typed port survives.
|
||||
@@ -17,7 +23,7 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
|
||||
- D-121 follow-ups (`4a4e45f`, `4b1ae0c`; D-122): `AppBuildInfo.releaseServerUrl` from `RELEASE_SERVER_URL`; S-22 shows the paired server, before pairing the build's release server; `DataModule.DEFAULT_SERVER_URL` removed; fakes and tests on `https://kvitt.example.org:8443` (30 S-21/S-22 goldens re-recorded); `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (refuses missing, `CHANGE_ME`, a URL, the old name) and passes `-Pkvitt.publicHost`.
|
||||
- Server host defaults (`c9abd24`, `e381e37`; D-123): `KVITT_PUBLIC_URL` required (every command exits 1 without it, before opening the database); `KVITT_TRUSTED_PROXY` defaults to `none`; OpenAPI server `https://{host}:8443` (portless since D-124) with variable `host` (default `kvitt.example.invalid`); E-7 and E-12 examples on `kvitt.example.invalid`; `tools/contracts-check.py` follows. 276 server tests.
|
||||
- `install.sh` names the NPM proxy host as README step 6 (`ac17b41`, no decision).
|
||||
- Temporary "Diagnose secrets" step in `.gitea/workflows/build-images.yaml` (`dd07ca4`, no decision), to be removed once the login failure's cause is known.
|
||||
- Temporary "Diagnose secrets" step in `.gitea/workflows/build-images.yaml` (`dd07ca4`, no decision), to be removed once the login failure's cause is known (removed in `3820f9c`).
|
||||
- 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 (replaced by the existing 443 forward, D-124). 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).
|
||||
@@ -34,11 +40,11 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
|
||||
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
|
||||
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
|
||||
- In flight:
|
||||
- This docs pass (`agent-docs-v023`: SPEC v0.23, D-124, HANDOFF, `prompts/README.md`).
|
||||
- Gitea Actions: the login step interpolates the secret in the `run` line (`4f69ae0`); the diagnosis found `kvitt_cicd` empty (§1); the owner is storing it under the right name (§3 item 1).
|
||||
- This docs pass (`agent-docs-v024b`: SPEC v0.24, D-125, HANDOFF, `prompts/README.md`, notes in `PLAN-M11.md` and `PLAN-M2c-pi.md`).
|
||||
- The owner installing Kvitt on the Kvitt Pi (§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 `038c603`. The owner created a Gitea secret meant as `kvitt_cicd` (not found under that name, §1) 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).
|
||||
- Remote: `origin` on Gitea, SSH key works; pushed at `6e5981d`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
|
||||
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 944 tests at `d090c6c`, one more test in `4a4e45f`; server 276 tests at `c9abd24`).
|
||||
- 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`.
|
||||
@@ -46,43 +52,44 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
|
||||
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
|
||||
|
||||
## 2. Open work (one line each, with who owns it: agent or owner)
|
||||
- Land `agent-docs-v023` (orchestrator): SPEC v0.23, D-124, HANDOFF, `prompts/README.md`, the S-1 TalkBack line of `docs/ux/design-spec.md`.
|
||||
- Closed by D-124, `038c603` and `ad35231`: the server's and the bundle's `:8443`, `RELEASE_SERVER_PORT`, the intent filter's port, the pinned-root config's old host name and the stale "pinned Caddy root" comment, `OnboardingStates.kt`'s `GOLDEN_SERVER`, the `:data` comments, the `FakeScenarios` and `SettingsScenarios` fixture URLs, `android/README.md`.
|
||||
- Push `ad35231` and the v0.23 docs commit to `origin` (orchestrator).
|
||||
- Remove the temporary "Diagnose secrets" workflow step (`dd07ca4`) once a run passes the login (agent, after §3 item 1).
|
||||
- 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).
|
||||
- Land `agent-docs-v024b` (orchestrator): SPEC v0.24, D-125, HANDOFF, `prompts/README.md`, the plan notes.
|
||||
- Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min).
|
||||
- Push `28fa71b` and the v0.24 docs commit to `origin` (orchestrator). `28fa71b` touches `.gitea/workflows/**`, so the push starts a workflow run (next tag `v1.0.2`, same image content; the server build should reuse the builder's cargo cache).
|
||||
- `infra/README.md` "Gitea Actions workflow" is stale in two places (agent, next infra pass): `CARGO_BUILD_JOBS` "takes effect only once `server/Dockerfile` declares it" (it does since `b4d9467`) and "the first build's duration on the Pi is unknown"; its "Owner decisions (open)" 1 (`write:repository`, moot: the tag push works) and 2 (host name, answered) are answered.
|
||||
- `infra/backup/Dockerfile`: stale comment "on the Pi the container runs as uid 10001" (since D-125 the owner's uid); left as is, because editing it triggers a 5-minute image build (agent, with the next change there).
|
||||
- Historical `:8443` and `icehack.asuscomm.com` mentions in `docs/ux/` and `docs/validation/` are left as the record of their time (no action unless the owner wants them rewritten).
|
||||
- Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit); `infra/RESTORE.md` is the runbook since D-125.
|
||||
- Possible firewall rule on the Kvitt Pi limiting port 8080 to the HA Pi (`HA_HOST`); not done, not decided (owner). D-125 makes no system changes, so it would be the owner's own step.
|
||||
- AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and the phones (M2c-pi run, M10).
|
||||
- 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).
|
||||
- State-class naming: `*Ui`, `*UiState` and `*State` are mixed across features; needs a decision if a convention is wanted (owner; no plan).
|
||||
- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs).
|
||||
- Owner runs the Pi bundle on the Kvitt Pi (§3 item 2); the checklist is `infra/README.md` "Pi: what the owner does" (nine steps; NPM at step 6 is done, the router at step 7 is a check only, D-124).
|
||||
- Plans (SPEC §11.2 v0.23), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle and its NPM rework, server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124): landed.
|
||||
- Next: the owner runs the bundle on the Kvitt Pi when he moves over (D-100; the image must carry the H-1 fix, which every image since `725901c` does); 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).
|
||||
- `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`.
|
||||
- Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124): landed.
|
||||
- Next: the owner installs Kvitt on the Kvitt Pi (§3; image `v1.0.1`, which carries the H-1 fix like every image since `725901c`); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure SPEC §10.8 "Production run", now in `~/kvitt/import/`, D-025, D-125; each run must reconcile, D-045).
|
||||
|
||||
## 3. HUMAN steps pending (relay in chat)
|
||||
1. Gitea secret: in Gitea, the user menu, Settings, Actions, Secrets, add the secret `KVITT_CICD` with the registry token (`write:package`). Then the repository, Actions, "Build Kvitt images", "Run workflow". Report: did "Log in to Gitea Container Registry" pass (yes/no), and did the run finish (yes/no; if "Tag and Push Version" failed with a permission error, say so).
|
||||
2. Pi: provision the Kvitt Pi and install Docker (`infra/README.md` "Pi: what the owner does" steps 1 and 2); fill `infra/pi/.env` (`HA_HOST`, `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`, `KVITT_TAG`); copy the bundle to the Pi; run `install.sh`. Expected: on the phone, `https://kvitt.suorra.duckdns.org/health` shows `"status":"ok"` (yes/no).
|
||||
3. Release app pairing, after item 2: run `kvitt-server bootstrap` on the Pi (`/opt/kvitt/bootstrap.sh`) and pair the release app with that code (relayed in chat with the exact screen labels when the time comes).
|
||||
1. Pi install (D-125; `infra/README.md` "Pi: what the owner does", steps 1 to 3). On the Mac: `cp infra/pi/.env.example infra/pi/.env`, set `HA_HOST` (NPM's LAN IPv4 address) and `KVITT_TAG=v1.0.1`, check `KVITT_UID`/`KVITT_GID` are 1000; `mkdir -p infra/pi/secrets && cp infra/secrets/fcm-service-account.json infra/secrets/rclone.conf infra/pi/secrets/`; `scp -r infra/pi/compose.yaml infra/pi/.env infra/pi/secrets jacobhagstedt@<kvitt-pi-address>:kvitt/`; `rm -r infra/pi/secrets`. On the Pi: `cd ~/kvitt`, `chmod 700 secrets && chmod 600 secrets/*`, `mkdir -p data/apk backups import`, `docker compose pull && docker compose up -d`, `docker compose ps`, `curl -s localhost:8080/health`. Expected: `server` and `backup` both `(healthy)` within a minute; `curl` prints `{"status":"ok",...,"db_ok":true}` (yes/no).
|
||||
2. From outside: on the phone with Wi-Fi off, open `https://kvitt.suorra.duckdns.org/health` in Chrome. Expected: `"status":"ok"` without a certificate warning (yes/no).
|
||||
3. First backup, bootstrap, release pairing. On the Pi in `~/kvitt`: `docker compose exec backup /backup.sh` ends with `backup: ok <stamp>` (yes/no); then `docker compose exec server kvitt-server bootstrap` prints the admin invite code, link and expiry; pair the release app `se.hagstedt.kvitt` with it (the orchestrator relays the exact screen labels then).
|
||||
4. Partner phone, after the owner's release app is paired: `docker compose exec server kvitt-server invite-partner` on the Pi; the partner installs the release APK and pairs with that invite (relayed with screen labels when the time comes).
|
||||
|
||||
## 4. Owner questions (asked, unanswered)
|
||||
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1".
|
||||
- Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`.
|
||||
- State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built?
|
||||
- `write:repository` for the registry secret, only if the workflow's tag push fails (§3 item 1).
|
||||
- Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124).
|
||||
- Answered 2026-10-20: Kvitt on the Pi is a self-contained compose project in `~/kvitt` with nothing installed elsewhere (D-125). Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124). Moot: `write:repository` for the registry secret (the tag push works with `write:package`).
|
||||
|
||||
## 5. Paused (waiting on something outside the repo)
|
||||
- Nothing.
|
||||
|
||||
## 6. Numbering
|
||||
- Decisions: D-001 to D-124 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-125**.
|
||||
- Spec version: v0.23 written; next **v0.24**.
|
||||
- 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.23.
|
||||
- Decisions: D-001 to D-125 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-126**.
|
||||
- Spec version: v0.24 written; next **v0.25**.
|
||||
- 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.24.
|
||||
- Sync protocol **1**, push payload **1**.
|
||||
|
||||
## 7. How the work is run
|
||||
|
||||
+5
-4
@@ -107,9 +107,9 @@ 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`~~ (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 <tag> [--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-<short sha>` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-<version_code>.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-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 <tag> [--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`~~ (revised by D-125: no scripts and no `/opt`; plain `docker compose` commands in `/home/jacobhagstedt/kvitt`; the backup before every deploy stays). 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-<short sha>` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-<version_code>.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~~ (revised by D-125: the owner `scp`s the APK, then `latest.json`, into `~/kvitt/data/apk/`; older APKs removed by hand); 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`.~~ (revised 2026-10-20: the informational keys dropped in `200c99f`, read by nothing on the Pi; the registry keys dropped by D-125, whose `.env` holds exactly the seven keys compose reads) 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` (revised by D-125) |
|
||||
| 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/<name>`). `.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-105 | 2026-10-18 | `backup` runs as ~~uid 10001~~ the server's uid on the Pi too (revised by D-125: on the Pi both containers run as the owner's uid, `KVITT_UID`/`KVITT_GID`; the development stack keeps 10001), 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~~ (revised by D-125: `~/kvitt/secrets/rclone.conf`, the owner's, mode 0600). ~~`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.~~ (`/certs` removed by D-120; revised by D-125: no `install.sh`, the owner creates `data/apk`, `backups` and `import` before the first start) 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 <dir> -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 |
|
||||
@@ -121,11 +121,12 @@ 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.~~ (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 `<UTC time> <KVITT_TAG> <image reference> <digest>` 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-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 `<UTC time> <KVITT_TAG> <image reference> <digest>` to `/opt/kvitt/deployed-digests`, with a WARNING when a recorded reference changed digest;~~ (revised by D-125: no digest record on the Pi; the owner compares `docker image inspect` with the workflow's digests) 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~~ the owner chose `kvitt.suorra.duckdns.org` on 2026-10-19), 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`~~ (revised by D-124: `https://${KVITT_PUBLIC_HOST}`, port 443). A path prefix on another name is not supported. (2) The router forwards external TCP ~~8443~~ 443 (revised by D-124: the existing forward for Home Assistant, no new rule) 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 (the server's built-in defaults, the OpenAPI server URL and the E-7/E-12 examples that still named the old host: revised by D-123). (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-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~~ the owner chose `kvitt.suorra.duckdns.org` on 2026-10-19), 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`~~ (revised by D-124: `https://${KVITT_PUBLIC_HOST}`, port 443). A path prefix on another name is not supported. (2) The router forwards external TCP ~~8443~~ 443 (revised by D-124: the existing forward for Home Assistant, no new rule) 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` (no check since D-125); the server is unchanged and takes the last `X-Forwarded-For` entry from that peer (the server's built-in defaults, the OpenAPI server URL and the E-7/E-12 examples that still named the old host: revised by D-123). (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.~~ (revised by D-125: no scripts; the owner runs the same two checks by hand, SPEC §10.5.1) (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~~ no port (revised by D-124), `/i/`, `autoVerify="false"`) and as `RELEASE_SERVER_URL` = ~~`https://<host>:8443`~~ (revised by D-124: `https://<host>`) (`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~~ landed `4b1ae0c`, D-122; S-22's fallback server also D-122). 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` |
|
||||
| D-122 | 2026-10-19 | S-22's server before pairing comes from the build (extends D-121). `AppBuildInfo` (defined in `:data` `di/DataModule.kt`, provided by `:app` in `di/AppBuildInfoModule.kt`, consumed by `:data`) gains `releaseServerUrl: String`, filled from `RELEASE_SERVER_URL` = ~~`https://<kvitt.publicHost>:8443`~~ (revised by D-124: `https://<kvitt.publicHost>`): `data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String)`. S-22's server row shows the paired server's base URL; while none is stored it shows `AppBuildInfo.releaseServerUrl` (`AboutRepositoryImpl`'s `releaseServerUrl`, was `defaultServerUrl`); `DataModule.DEFAULT_SERVER_URL` (`https://icehack.asuscomm.com:8443`) is removed, so `:data` names no host. Fakes and tests use `https://kvitt.example.org:8443` (`FakeScenarios`, `SettingsScenarios`, `RepositoriesTest`; 30 S-21/S-22 goldens re-recorded with that fixture host, only the host changed); new test `about_unpaired_showsTheReleaseServerOfTheBuild`. `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` without sourcing it (refuses a missing file or key, `CHANGE_ME`, a value that is not a host name such as a URL, or the old name `icehack.asuscomm.com`, the checks of `install.sh`), passes `-Pkvitt.publicHost` to `:app:assembleRelease` and prints the release server in its header and summary. Landed `4a4e45f` (Android) and `4b1ae0c` (`publish-apk.sh`, `infra/README.md`). | D-121 left S-22's unpaired fallback on Home Assistant's host and `publish-apk.sh` without the host, so a release could be built for the placeholder. | SPEC §6.2, §10.6, §13.3 S-22, §14.2.4 |
|
||||
| D-123 | 2026-10-19 | The server has no built-in Kvitt host (revises D-120's leftovers). (1) `KVITT_PUBLIC_URL` has no default (`DEFAULT_PUBLIC_URL` removed from `config.rs`): unset or empty, every `kvitt-server` command exits 1 with a configuration error (`configuration: KVITT_PUBLIC_URL is not set …`) before opening the database; the Pi compose sets ~~`https://${KVITT_PUBLIC_HOST}:8443`~~ (revised by D-124: `https://${KVITT_PUBLIC_HOST}`), the dev stack `http://<Mac LAN address>:<port>` (`infra/dev/up.sh`; compose fallback `http://localhost:8080`). (2) `KVITT_TRUSTED_PROXY` defaults to `none` (`NO_TRUSTED_PROXY`, ignoring case and surrounding white space): no peer trusted and the value never looked up; empty or unresolvable also means no proxy (D-022 unchanged); compose sets `HA_HOST`. (3) The OpenAPI server is ~~`https://{host}:8443`~~ (revised by D-124: `https://{host}`) with server variable `host`, default `kvitt.example.invalid` (RFC 6761 reserved), description 'the owner-chosen Kvitt host name, SPEC §10.5', in `contracts/openapi.yaml` and the generated document (`openapi.rs` `PUBLIC_SERVER_URL`, `HOST_VARIABLE_DEFAULT`); operations unchanged (representational for §14.8). (4) Examples E-7 `public_url` and E-12 `link` use ~~`https://kvitt.example.invalid:8443`~~ (revised by D-124: `https://kvitt.example.invalid`). `tools/contracts-check.py` expects the `{host}` URL (`e381e37`). Tests: CLI `every_command_refuses_to_start_without_a_public_url` (`serve`, `bootstrap`, `migrate`; no database created), config `public_url_is_required`, rate limiter `the_default_none_trusts_no_peer`, `an_unresolvable_name_trusts_no_peer`, `an_ip_literal_is_trusted`; 276 server tests. `server/README.md` updated. Landed `c9abd24`, `e381e37`. | Since D-120 the old name `icehack.asuscomm.com` is Home Assistant's; a wrong built-in host is worse than none, and `caddy` named a container that no longer exists. | SPEC §10.4, §11.3, §14.3, §14.3.4 (E-7, E-12) |
|
||||
| D-124 | 2026-10-19 | Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the `:8443` of D-120 to D-123). (1) Server and contracts (`a86ad24`): the OpenAPI server is `https://{host}` (`contracts/openapi.yaml`, `openapi.rs` `PUBLIC_SERVER_URL`); examples E-7 `public_url` and E-12 `link` use `https://kvitt.example.invalid` without a port; `tools/contracts-check.py` expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` in `infra/pi/compose.yaml`; the health checks through NPM use `https://<KVITT_PUBLIC_HOST>/health` on 443 (`infra/pi/lib.sh`); `infra/publish-apk.sh` prints the release server without a port; `infra/README.md` step 7 is "Router: no new rule" (verify the existing TCP 443 rule to `<HA_HOST>`); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (`038c603`): `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 `https` intent filter carries no `android:port`, which matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443` for `https`, `:80` for `http`); the S-1 fixture `GOLDEN_SERVER` is `https://kvitt.example.org` (28 S-1 goldens re-recorded, only the server row changed). | Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §14.2.4, §14.3, E-7, E-12 |
|
||||
| D-125 | 2026-10-20 | The Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project: `compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`; containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi); no install script, no `/opt`, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain `docker compose` commands. Details: `.env` holds exactly the seven keys compose reads (`KVITT_REGISTRY`, `KVITT_TAG`, `KVITT_PUBLIC_HOST`, `HA_HOST`, `KVITT_UID`, `KVITT_GID`, `RCLONE_FOLDER_ID`); `RCLONE_REMOTE` fixed to `gdrive:` in compose; private packages → one-time `docker login gitea.lan`; each container mounts only its own secret file read-only (`create_host_path: false`); read-only root, 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; secret files the owner's, mode 0600. Equivalents of the removed scripts (`install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`): backup `docker compose exec backup /backup.sh`; bootstrap `docker compose exec server kvitt-server bootstrap`; partner invite `docker compose exec server kvitt-server invite-partner`; deploy = backup, set `KVITT_TAG`, `pull`, `up -d`; rollback = the previous tag; APK = `scp` the APK, then `latest.json`, into `data/apk/` (old APKs removed by hand); digests via `docker image inspect`. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, `/opt/kvitt`, `.env` keys), D-105 (uid and `rclone.conf` owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed `6e5981d`. Rehearsed locally with Podman as uid 501: both containers healthy, `/health` 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. | Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/README.md` "Pi: what the owner does", `infra/RESTORE.md`, `PLAN-M11.md` and `PLAN-M2c-pi.md` notes |
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user