6 Commits

Author SHA1 Message Date
jacobh2 4852803cdb Pi bundle: Kvitt on port 443 through NPM (D-124)
Build Kvitt images / build (push) Successful in 2h51m36s
KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST} with no port; the health check
through NPM prints the port-less URL; publish-apk.sh's summary drops :8443.
README: no new router rule (the existing external 443 forward to the Home
Assistant Pi serves Kvitt, NPM selects by name), NPM listens on 443, the
fallback, operations and APK-abuse notes, and every :8443 URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:23:18 +02:00
jacobh2 a86ad241f1 Server and contracts: public URL on port 443 (D-124)
Kvitt is served on the default HTTPS port through Nginx Proxy Manager, so the
OpenAPI server is https://{host} (contracts/openapi.yaml and openapi.rs
PUBLIC_SERVER_URL), examples E-7 public_url and E-12 link are
https://kvitt.example.invalid without a port, tools/contracts-check.py expects
the portless header, and the server's tests, config message and README name
no port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:22:09 +02:00
jacobh2 cce3e4e9d9 SPEC v0.22: D-122 S-22's release server and publish-apk host; D-123 no built-in server host; Kvitt host name
D-122 (4a4e45f, 4b1ae0c): AppBuildInfo.releaseServerUrl, S-22 before pairing, publish-apk.sh reads
KVITT_PUBLIC_HOST from infra/pi/.env. D-123 (c9abd24, e381e37): KVITT_PUBLIC_URL required,
KVITT_TRUSTED_PROXY defaults to none, OpenAPI server https://{host}:8443, E-7/E-12 examples on
kvitt.example.invalid. The owner's host name kvitt.suorra.duckdns.org recorded in HANDOFF (SPEC §10.1,
§10.5.1 point there). HANDOFF and prompts/README.md status at ac17b41.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:19:00 +02:00
jacobh2 ac17b41a3e install.sh: the NPM proxy host is README step 6
Build Kvitt images / build (push) Failing after 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
jacobh2 e381e371ea contracts-check: OpenAPI header expects the {host} server variable (D-123)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
jacobh2 c9abd24c77 Server: no built-in public host, no trusted proxy by default; OpenAPI server variable host (D-123)
KVITT_PUBLIC_URL is required: every command refuses to start without it (compose
always sets it). KVITT_TRUSTED_PROXY defaults to none, which the rate limiter never
resolves. contracts/openapi.yaml and the utoipa document: servers[].url
https://{host}:8443 with variable host (default kvitt.example.invalid). E-7 and E-12
examples on kvitt.example.invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:11:46 +02:00
24 changed files with 261 additions and 113 deletions
+20 -13
View File
@@ -1,6 +1,6 @@
# Kvitt: specification # Kvitt: specification
Version **0.21** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**. Version **0.22** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. 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.
@@ -440,7 +440,7 @@ Module graph (directory `android/`):
**Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `<name>_sv_light.png`, `<name>_sv_dark.png` and `<name>_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `<name>_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_<light|dark>.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-h<height>dp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that `:feature:home` and `:feature:account` captured the previous variant, so their committed `sv_dark` (and some `_sv_light_200` and dialog) goldens were one variant stale, fixed and re-recorded in `dc7a296` and `5b2ed37`. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. Since the Android overhaul (`c582363`, D-118) the harness is shared: `:core:design`'s test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`) hold `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with the capture modes `Root`, `Screen` and `ScreenWhenIdle`, `MainDispatcherRule` and `withMain`, consumed as `testImplementation(testFixtures(project(":core:design")))`. Test fixtures are not a module, so the module graph below is unchanged; the architecture check rejects a module's test fixtures in any configuration other than a test configuration. The convention plugin `kvitt.android.screenshots` wires Roborazzi (plugin, `outputDir` `src/test/screenshots`) and the test libraries; feature build scripts carry no screenshot block. **Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `<name>_sv_light.png`, `<name>_sv_dark.png` and `<name>_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `<name>_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_<light|dark>.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-h<height>dp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that `:feature:home` and `:feature:account` captured the previous variant, so their committed `sv_dark` (and some `_sv_light_200` and dialog) goldens were one variant stale, fixed and re-recorded in `dc7a296` and `5b2ed37`. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. Since the Android overhaul (`c582363`, D-118) the harness is shared: `:core:design`'s test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`) hold `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with the capture modes `Root`, `Screen` and `ScreenWhenIdle`, `MainDispatcherRule` and `withMain`, consumed as `testImplementation(testFixtures(project(":core:design")))`. Test fixtures are not a module, so the module graph below is unchanged; the architecture check rejects a module's test fixtures in any configuration other than a test configuration. The convention plugin `kvitt.android.screenshots` wires Roborazzi (plugin, `outputDir` `src/test/screenshots`) and the test libraries; feature build scripts carry no screenshot block.
**Hilt wiring (D-081, M8).** Both variants bind every `:core:model` interface to `:data`: `android/app/src/main/.../di/AppDataModule.kt` includes `DataBindingsModule` (D-041), and `AppBuildInfo` comes from `BuildConfig` in both variants. The debug fakes module (`a41f240`) and the release-only data module were deleted by M8; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews and never reach an installed build; `:app`'s unit tests run on a plain `Application`, not the Hilt application. **Hilt wiring (D-081, M8).** Both variants bind every `:core:model` interface to `:data`: `android/app/src/main/.../di/AppDataModule.kt` includes `DataBindingsModule` (D-041), and `AppBuildInfo` (defined in `:data`'s `di/DataModule.kt`, provided by `:app`'s `di/AppBuildInfoModule.kt`, §14.2.4) comes from `BuildConfig` in both variants and also carries `RELEASE_SERVER_URL` as `releaseServerUrl` (D-122). The debug fakes module (`a41f240`) and the release-only data module were deleted by M8; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews and never reach an installed build; `:app`'s unit tests run on a plain `Application`, not the Hilt application.
Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`. Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`.
@@ -880,7 +880,7 @@ All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `i
| 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 | | 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` and selects the proxy host by name, so Kvitt has its own name. The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH | | 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` and selects the proxy host by name, so Kvitt has its own name. The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
| Owner's M4 Mac | Builds release APKs (`infra/publish-apk.sh`); builds images only as the fallback of the Gitea workflow (`infra/build-images.sh`, D-104); runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker | | 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, recorded in HANDOFF) points at the same public address (a DuckDNS name is being set up) | Forwards external TCP 8443 to the Home Assistant Pi's TCP 443 (NPM); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (HUMAN step, D-120) | | 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`) | Forwards external TCP 8443 to the Home Assistant Pi's TCP 443 (NPM); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (HUMAN step, D-120) |
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them | | Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them |
The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them. The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them.
@@ -925,10 +925,10 @@ Environment variables (exhaustive):
|---|---|---| |---|---|---|
| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite`, `pictures/`, `apk/` | | `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite`, `pictures/`, `apk/` |
| `KVITT_BIND` | `0.0.0.0:8080` | Listen address | | `KVITT_BIND` | `0.0.0.0:8080` | Listen address |
| `KVITT_PUBLIC_URL` | `https://<KVITT_PUBLIC_HOST>:8443`, set by the Pi's compose from the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) (D-120) | Used in invite links and the landing page. The built-in fallback in code (`DEFAULT_PUBLIC_URL` in `config.rs`) is never used on the Pi, where compose always sets the variable; the development stack sets `http://<Mac LAN address>:<port>` | | `KVITT_PUBLIC_URL` | none: required (D-123) | Used in invite links and the landing page. Unset or empty, every `kvitt-server` command exits 1 with a configuration error (`configuration: KVITT_PUBLIC_URL is not set …` on stderr) before opening the database; there is no built-in host (the fallback `DEFAULT_PUBLIC_URL` was removed by D-123). The Pi's compose sets `https://${KVITT_PUBLIC_HOST}:8443` from the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) (D-120); the development stack sets `http://<Mac LAN address>:<port>` (`infra/dev/up.sh`; the dev compose falls back to `http://localhost:8080`) |
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start | | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project |
| `KVITT_TRUSTED_PROXY` | `caddy` | IP literal or host name whose resolved address may set `X-Forwarded-For` (not a CIDR). The default `caddy` no longer resolves and means no proxy; compose sets it to `HA_HOST`, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy); then no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) | | `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name whose resolved address may set `X-Forwarded-For` (not a CIDR). The default `none` (`NO_TRUSTED_PROXY` in `config.rs`, compared ignoring case and surrounding white space) means no proxy and is never looked up (D-123; was `caddy`); an empty value counts as unset. Compose sets it to `HA_HOST`, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy, D-022 unchanged); with `none` or an unresolvable name no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) |
| `RUST_LOG` | `info` | tracing filter | | `RUST_LOG` | `info` | tracing filter |
The time zone `Europe/Stockholm` is a constant in code, not configuration. The time zone `Europe/Stockholm` is a constant in code, not configuration.
@@ -955,7 +955,7 @@ Run them on the Pi with `docker compose exec server kvitt-server <command>` (or
### 10.5 TLS ### 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`; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}:8443`. Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 6; no agent and no script touches NPM. 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}:8443`. Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 6; no agent and no script touches NPM.
#### 10.5.1 Nginx Proxy Manager #### 10.5.1 Nginx Proxy Manager
@@ -963,7 +963,7 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
| Tab | Field | Value | | Tab | Field | Value |
|---|---|---| |---|---|---|
| Details | Domain Names | `<KVITT_PUBLIC_HOST>` | | Details | Domain Names | `<KVITT_PUBLIC_HOST>` (currently `kvitt.suorra.duckdns.org`, HANDOFF) |
| Details | Scheme | `http` | | Details | Scheme | `http` |
| Details | Forward Hostname / IP | `<KVITT_PI_HOST>` (the Kvitt Pi's LAN IPv4 address) | | Details | Forward Hostname / IP | `<KVITT_PI_HOST>` (the Kvitt Pi's LAN IPv4 address) |
| Details | Forward Port | `8080` | | Details | Forward Port | `8080` |
@@ -1054,7 +1054,7 @@ 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. 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). * 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, builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode` and `-Pkvitt.versionName`, 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`: * `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>:8443` 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`:
```json ```json
{ {
@@ -1067,7 +1067,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, port 8443, path prefix `/i/`, `android:autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://<host>:8443` (`link/ReleaseServer.kt`); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1). Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (§10.5); the owner may instead set the key in `android/local.properties`. * The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, path prefix `/i/`, `android:autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://<host>:8443` (`link/ReleaseServer.kt`); `: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 (AC-2.6). 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`. * 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. * Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints.
* Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103). * Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103).
@@ -1277,7 +1277,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
### 11.3 Notes per milestone ### 11.3 Notes per milestone
* **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). * **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). `tools/contracts-check.py` checks the file's header: OpenAPI 3.1.0, title `Kvitt`, version `1` and one server `https://{host}:8443` whose variable `host` has the default `kvitt.example.invalid` (v0.22, D-123; was the literal `https://icehack.asuscomm.com:8443`).
* **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3). * **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3).
* **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018: `IncompleteCustomRangeException`, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and `KvittDatabase` v1 in `:data` (D-012), so M5 builds on them. * **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018: `IncompleteCustomRangeException`, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and `KvittDatabase` v1 in `:data` (D-012), so M5 builds on them.
* **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3). * **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3).
@@ -1382,7 +1382,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete | | S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete |
| S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) | | S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) |
| S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel | | S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel |
| S-22 | About | App version name and code, server URL, server version from E-1 `/health`, fetched when S-22 opens while online, shown as "<version> (schema <n>)" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"); offline or when the call fails, the `X-Kvitt-Server-Version` header of the last response without the schema (`AboutState`, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092) | Check for update | | S-22 | About | App version name and code, server URL (the paired server; before pairing the build's release server, `AppBuildInfo.releaseServerUrl`, D-122), server version from E-1 `/health`, fetched when S-22 opens while online, shown as "<version> (schema <n>)" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"); offline or when the call fails, the `X-Kvitt-Server-Version` header of the last response without the schema (`AboutState`, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092) | Check for update |
System notifications (copy needed in both languages, built per AC-13.5): System notifications (copy needed in both languages, built per AC-13.5):
@@ -2037,7 +2037,11 @@ interface UpdateRepository {
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?, val schemaVersion: Int?) // schemaVersion: v0.18, D-110 data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?, val schemaVersion: Int?) // schemaVersion: v0.18, D-110
// D-110: serverVersion and schemaVersion from E-1 /health when S-22 opens online; offline or on failure serverVersion is the // D-110: serverVersion and schemaVersion from E-1 /health when S-22 opens online; offline or on failure serverVersion is the
// X-Kvitt-Server-Version header of the last response and schemaVersion is null. // X-Kvitt-Server-Version header of the last response and schemaVersion is null.
// D-122: serverUrl is the paired server's base URL; while none is stored, AppBuildInfo.releaseServerUrl.
interface AboutRepository { fun about(): Flow<AboutState> } interface AboutRepository { fun about(): Flow<AboutState> }
// Not a :core:model type: defined in :data (di/DataModule.kt), provided by :app (di/AppBuildInfoModule.kt) from BuildConfig and
// RELEASE_SERVER_URL = https://<kvitt.publicHost>:8443 (D-121), consumed by :data.
data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String) // releaseServerUrl: v0.22, D-122
interface ConnectivityRepository { val online: Flow<Boolean> } // §9.9 interface ConnectivityRepository { val online: Flow<Boolean> } // §9.9
// Push (§14.6), v0.13, D-095: model/Push.kt; :adapter:fcm maps FCM data messages into it // Push (§14.6), v0.13, D-095: model/Push.kt; :adapter:fcm maps FCM data messages into it
@@ -2102,7 +2106,7 @@ Readings fixed in v0.5 (no type changes):
### 14.3 OpenAPI 3.1 outline ### 14.3 OpenAPI 3.1 outline
Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://icehack.asuscomm.com:8443`, security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything. Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://{host}:8443` with the server variable `host`, default `kvitt.example.invalid` (reserved by RFC 6761), description "the owner-chosen Kvitt host name, SPEC §10.5" (D-123; was `https://icehack.asuscomm.com:8443`; no operation changed, representational under §14.8), security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything.
#### 14.3.1 Common rules #### 14.3.1 Common rules
@@ -2254,6 +2258,8 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://<KVITT_PUBLIC_HOST>:8443" } { "user": User, "device": Device, "household": [User, ...], "public_url": "https://<KVITT_PUBLIC_HOST>:8443" }
``` ```
The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "https://kvitt.example.invalid:8443"` (D-123).
**E-8 `PATCH /me`** (device). Request `{ "display_name": string }`. 200 `User`. Errors: `invalid_request`, `validation_failed`. **E-8 `PATCH /me`** (device). Request `{ "display_name": string }`. 200 `User`. Errors: `invalid_request`, `validation_failed`.
**E-9 `PUT /me/picture`** (device). Raw image body. 200 `User` (new `picture_version`). Errors: `payload_too_large`, `unsupported_media_type`, `image_invalid`. **E-9 `PUT /me/picture`** (device). Raw image body. 200 `User` (new `picture_version`). Errors: `payload_too_large`, `unsupported_media_type`, `image_invalid`.
@@ -2262,7 +2268,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
**E-11 `GET /users/{user_id}/picture?size=128|512&v=<picture_version>`** (device). 200 `image/jpeg`, `ETag: "<picture_version>"`, `Cache-Control: private, max-age=31536000, immutable`. `size` defaults to 512; other values `invalid_request`. `v` is a cache key only and is ignored. Errors: `not_found` (unknown user or no picture), `invalid_request`. **E-11 `GET /users/{user_id}/picture?size=128|512&v=<picture_version>`** (device). 200 `image/jpeg`, `ETag: "<picture_version>"`, `Cache-Control: private, max-age=31536000, immutable`. `size` defaults to 512; other values `invalid_request`. `v` is a cache key only and is ignored. Errors: `not_found` (unknown user or no picture), `invalid_request`.
**E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<percent-encoded admin display name>`, D-087; the app also reads links without `from`). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user). **E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<percent-encoded admin display name>`, D-087; the app also reads links without `from`; the example's `link` is `https://kvitt.example.invalid:8443/i/4N8RVT2KW6ZC?from=Jacob`, D-123). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user).
**E-13 `GET /invites`** (admin). 200 `{ "invites": [Invite, ...] }` (pending only: not accepted, not cancelled, not expired; `code` and `link` null). Errors: `forbidden`. **E-13 `GET /invites`** (admin). 200 `{ "invites": [Invite, ...] }` (pending only: not accepted, not cancelled, not expired; `code` and `link` null). Errors: `forbidden`.
@@ -2861,3 +2867,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.19 | 2026-10-18 | The Android overhaul (`e4e6e5d` to `c582363`). D-118: shared test support in `:core:design`'s test fixtures (`se.hagstedt.kvitt.design.testing`: `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with `Root`/`Screen`/`ScreenWhenIdle`, `MainDispatcherRule`, `withMain`) through `testImplementation(testFixtures(project(":core:design")))`, not a module, the architecture check rejects test fixtures outside test configurations; the convention plugin `kvitt.android.screenshots`; `format/Failures.kt`, `format/Limits.kt` and `savedSplitLabel` in `:core:design`; `KvittMotion` only the seesaw spring, `KvittIcon.Description` and unused dimens and shapes removed; four catalog entries removed; one `CopyCategoryKeywords` task (§6.2 `:core:design` row and Screenshot tests, §11.1, §11.3; design-spec §2.11 and the icon table). Without a decision id: overhaul outcomes in §11.3 (Android `c582363`, server `b985ce9`, infra `e70fe0d`); 941 tests, no golden changed, release APK 10.46 MB; pushed to `origin` at `c582363`; `infra/README.md` APK size about 10 MB; pre-rewrite hashes in `PLAN-*.md` replaced by the rewritten ones. | | 0.19 | 2026-10-18 | The Android overhaul (`e4e6e5d` to `c582363`). D-118: shared test support in `:core:design`'s test fixtures (`se.hagstedt.kvitt.design.testing`: `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with `Root`/`Screen`/`ScreenWhenIdle`, `MainDispatcherRule`, `withMain`) through `testImplementation(testFixtures(project(":core:design")))`, not a module, the architecture check rejects test fixtures outside test configurations; the convention plugin `kvitt.android.screenshots`; `format/Failures.kt`, `format/Limits.kt` and `savedSplitLabel` in `:core:design`; `KvittMotion` only the seesaw spring, `KvittIcon.Description` and unused dimens and shapes removed; four catalog entries removed; one `CopyCategoryKeywords` task (§6.2 `:core:design` row and Screenshot tests, §11.1, §11.3; design-spec §2.11 and the icon table). Without a decision id: overhaul outcomes in §11.3 (Android `c582363`, server `b985ce9`, infra `e70fe0d`); 941 tests, no golden changed, release APK 10.46 MB; pushed to `origin` at `c582363`; `infra/README.md` APK size about 10 MB; pre-rewrite hashes in `PLAN-*.md` replaced by the rewritten ones. |
| 0.20 | 2026-10-19 | APK download rate limit (`96e4cbd`) and owner answers to the security review. D-119: E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own fixed window, not counted against the 60 per minute of the other unauthenticated endpoints, every request counted (a 404 included), 429 `rate_limited` with `Retry-After`, client IP as in D-032, in memory; E-5 and E-4 stay on the 60 (§8.6, §10.6, E-6). Without a decision id: security review M-1 closed by the owner (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in §8.1 and §8.7); L-3 fixed; the D-114 limits (16 megapixels, 512-character `fcm_token`) confirmed by the owner (§11.4). | | 0.20 | 2026-10-19 | APK download rate limit (`96e4cbd`) and owner answers to the security review. D-119: E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own fixed window, not counted against the 60 per minute of the other unauthenticated endpoints, every request counted (a 404 included), 429 `rate_limited` with `Retry-After`, client IP as in D-032, in memory; E-5 and E-4 stay on the 60 (§8.6, §10.6, E-6). Without a decision id: security review M-1 closed by the owner (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in §8.1 and §8.7); L-3 fixed; the D-114 limits (16 megapixels, 512-character `fcm_token`) confirmed by the owner (§11.4). |
| 0.21 | 2026-10-19 | The NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`) and the Android host property (`d090c6c`). D-120: TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (`infra/cert-sync/**`, `infra/pi/Caddyfile`, images, volumes `certs` and `caddy_data`, secrets `ha_ssh_key` and `ha_known_hosts`, `.env` keys `HA_PORT`, `HA_USER`, `HA_CERT_DIR`); Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen, recorded in HANDOFF), `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`; router 8443 → NPM 443, nothing forwarded to the Kvitt Pi; the server publishes 8080 on the LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}`; the NPM proxy host and its Custom Nginx Configuration carry over D-117's headers and log redaction (§10.5 rewritten: 10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback: NPM down); health checks on 8080 and through NPM; the Gitea login interpolates the secret in the `run` line (reverses L-8's fix) (§1, AC-1.1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.5, §10.7, §10.10, §11.2, §11.3, §11.4, OQ-3, OQ-6, E-7, §14.7). D-121 (`d090c6c`): the Android app's public host is the build property `kvitt.publicHost` (Gradle property, else `android/local.properties`, else the placeholder `kvitt.example.invalid`), exposed as `BuildConfig.PUBLIC_HOST`, the manifest placeholder `publicHost` and `RELEASE_SERVER_URL` = `https://<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.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). |
@@ -3,7 +3,7 @@
"kind": "partner", "kind": "partner",
"target_user_id": null, "target_user_id": null,
"code": "4N8RVT2KW6ZC", "code": "4N8RVT2KW6ZC",
"link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC?from=Jacob", "link": "https://kvitt.example.invalid/i/4N8RVT2KW6ZC?from=Jacob",
"created_at": "2026-10-17T06:30:02.004Z", "created_at": "2026-10-17T06:30:02.004Z",
"expires_at": "2026-10-20T06:30:02.004Z" "expires_at": "2026-10-20T06:30:02.004Z"
} }
+1 -1
View File
@@ -39,5 +39,5 @@
"server_version": 16 "server_version": 16
} }
], ],
"public_url": "https://icehack.asuscomm.com:8443" "public_url": "https://kvitt.example.invalid"
} }
+5 -1
View File
@@ -23,7 +23,11 @@ info:
`protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`, `protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`,
`internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4. `internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4.
servers: servers:
- url: https://icehack.asuscomm.com:8443 - url: https://{host}
variables:
host:
default: kvitt.example.invalid
description: the owner-chosen Kvitt host name, SPEC §10.5
security: security:
- device: [] - device: []
+29 -24
View File
@@ -3,9 +3,15 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand ## 1. Where things stand
- `main` at `d090c6c` (the Android host property, D-121, on top of `9231a1d`, the end of the NPM rework, D-120) plus the SPEC v0.21 docs commit from `agent-docs-v021` once it lands. `origin/main` (Gitea) at `d090c6c`: everything is pushed (`9231a1d` triggered the Actions run of §3 item 1; `d090c6c` touches only `android/` and triggers none). - `main` at `ac17b41` (the `install.sh` step-number fix, on top of D-123 `c9abd24`/`e381e37`, D-122 `4a4e45f`/`4b1ae0c`, the temporary workflow diagnostic `dd07ca4` and SPEC v0.21 `4bcff2c`) plus the SPEC v0.22 docs commit from `agent-docs-v022` once it lands. `origin/main` (Gitea) at `ac17b41`: everything is pushed.
- **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:8443`. An agent is writing it into `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`), both git-ignored. SPEC §10.1 and §10.5.1 point here.
- 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 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). - 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.
- Landed since SPEC v0.21 (recorded in v0.22):
- 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` 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.
- Landed since SPEC v0.20 (recorded in v0.21): - 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. - `cargo audit` in the server gate (`f1abb0d`, no decision): the gate is `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test`; the pre-commit hook runs `cargo audit --no-fetch` only when `server/Cargo.lock` or a server `Cargo.toml` is staged. First run: 0 advisories over 319 crates.
- NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`; D-120): TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi; Caddy, cert-sync, the Caddyfile, the HA SSH key and the router forward to the Kvitt Pi are gone. Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant. Router external 8443 → HA Pi 443. The server publishes 8080 on the Kvitt Pi's LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}` (NPM's IPv4, checked by `install.sh`). SPEC §10.5 rewritten (NPM proxy host, the Custom Nginx Configuration with D-117's headers and the `/i/` log exclusion, fallback: no LAN path for release builds). Rehearsed on the Mac: `nginx -t` against NPM v2.16.0 templates, 21 checks of the server behind nginx passed. The Gitea login interpolates the secret in the `run` line (the env form came through empty on act_runner 0.6.1). - NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`; D-120): TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi; Caddy, cert-sync, the Caddyfile, the HA SSH key and the router forward to the Kvitt Pi are gone. Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant. Router external 8443 → HA Pi 443. The server publishes 8080 on the Kvitt Pi's LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}` (NPM's IPv4, checked by `install.sh`). SPEC §10.5 rewritten (NPM proxy host, the Custom Nginx Configuration with D-117's headers and the `/i/` log exclusion, fallback: no LAN path for release builds). Rehearsed on the Mac: `nginx -t` against NPM v2.16.0 templates, 21 checks of the server behind nginx passed. The Gitea login interpolates the secret in the `run` line (the env form came through empty on act_runner 0.6.1).
@@ -22,24 +28,23 @@ 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 must be fixed before port 8443 is opened. - Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 must be fixed before port 8443 is opened.
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18. - 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: - In flight:
- This docs pass (`agent-docs-v021`: SPEC v0.21, D-120, D-121, HANDOFF, `prompts/README.md`, the PLAN-M2c-pi note, dated notes in the security review). - This docs pass (`agent-docs-v022`: SPEC v0.22, D-122, D-123, HANDOFF, `prompts/README.md`).
- Dev server rebuild (orchestrator/agent). - The host name into `infra/pi/.env` and `android/local.properties` (agent, git-ignored files only).
- D-121 follow-ups: `:data` `DataModule.DEFAULT_SERVER_URL` is still the old literal `https://icehack.asuscomm.com:8443` (S-22's fallback) and moves to `AppBuildInfo.releaseServerUrl`; `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (follow-up agent running). - Gitea Actions: the login failed with "password is empty" in the env form, and the run-line form is in place (`4f69ae0`); `dd07ca4` added a temporary "Diagnose secrets" step to find the cause (owner, §3 item 1).
- Gitea Actions: the first run (2026-10-19) got as far as the login (container, `apk add`, checkout worked) and failed with "password is empty" (env-based form); the run triggered by the `9231a1d` push tests the fixed login step (owner, §3 item 1).
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2). - 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. - 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 `d090c6c`. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted). - Remote: `origin` on Gitea, SSH key works; pushed at `ac17b41`. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 944 tests at `d090c6c`). - 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). - 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`. - Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`.
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, installed 2026-10-19: About and the release app opening were confirmed; release pairing is deferred to production (release builds require `https`, AC-2.8). - Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, installed 2026-10-19: About and the release app opening were confirmed; release pairing is deferred to production (release builds require `https`, AC-2.8).
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB. - 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) ## 2. Open work (one line each, with who owns it: agent or owner)
- Land `agent-docs-v021` (orchestrator): SPEC v0.21, D-120, D-121, HANDOFF, `prompts/README.md`, PLAN-M2c-pi note, security-review notes. - Land `agent-docs-v022` (orchestrator): SPEC v0.22, D-122, D-123, HANDOFF, `prompts/README.md`.
- D-121 follow-ups (in flight): `:data` `DataModule.DEFAULT_SERVER_URL` → `AppBuildInfo.releaseServerUrl`; `infra/publish-apk.sh` passes `-Pkvitt.publicHost`. - Closed by D-122, D-123 and `ac17b41`: the server's `DEFAULT_PUBLIC_URL` and `DEFAULT_TRUSTED_PROXY`, `openapi.rs` and `contracts/openapi.yaml` server URL, the E-7 and E-12 examples, `server/README.md`, the fakes' `AboutState` URLs, `DataModule.DEFAULT_SERVER_URL`, `publish-apk.sh`'s host, `install.sh`'s step number.
- Literals left by D-120 and D-121 (agent, no plan yet): the server's `DEFAULT_PUBLIC_URL` (`https://icehack.asuscomm.com:8443`) and `DEFAULT_TRUSTED_PROXY` (`caddy`) in `config.rs`, `openapi.rs`'s `PUBLIC_SERVER_URL`, `contracts/openapi.yaml`'s server URL and the E-7 and E-12 examples, `server/README.md`, the fakes' `AboutState` URLs; harmless (compose always sets the variables) but they name Home Assistant's host; a contract change needs the §14.8 rule. The pinned-root config's domain (D-082) follows only if that fallback is prepared. - Old host name still in the Android tree (agent, no plan yet): the pinned-root config's domain (`android/app/src/releasePinnedRoot/res/xml/network_security_config.xml`, D-082; follows `kvitt.publicHost` only if that fallback is prepared) and the stale comment "pinned Caddy root … icehack.asuscomm.com" at `android/app/build.gradle.kts:35`; `OnboardingStates.kt`'s `GOLDEN_SERVER` fixture (old name, test fixture only, a golden change if replaced).
- `install.sh`'s last line says "Next: README step 7 (NPM proxy host)"; the NPM step is 6 (agent, one-line fix in `infra/pi/install.sh`). - Remove the temporary "Diagnose secrets" workflow step (`dd07ca4`) once the login failure's cause is known (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). - 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. - `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). - Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit).
@@ -49,31 +54,31 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Security review L-10: `forbidden` or `not_found` for the other user's device id (owner decision, §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). - State-class naming: `*Ui`, `*UiState` and `*State` are mixed across features; needs a decision if a convention is wanted (owner; no plan).
- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs). - Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs).
- Owner runs the Pi bundle on the Kvitt Pi: deferred by the owner on 2026-10-19 ("when it makes sense to move over to the Pi"); the checklist is the D-120 one (nine steps, NPM at step 6, router at step 7). - Owner runs the Pi bundle on the Kvitt Pi when ready (§3 item 3); the checklist is the D-120 one (nine steps, NPM at step 6, router at step 7). The NPM proxy host and the router forward come first (§3 item 2).
- Plans (SPEC §11.2 v0.21), in dependency order; prompt `prompts/<id>.md`: - Plans (SPEC §11.2 v0.22), 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` and the Android host property: landed. - 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): landed.
- Next: the owner runs the bundle on the Kvitt Pi when he moves over (D-100, deferred; a server image with the H-1 fix before the router forward); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). - Next: the owner runs the bundle on the Kvitt Pi when he moves over (D-100, deferred; a server image with the H-1 fix before the router forward); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045).
## 3. HUMAN steps pending (relay in chat) ## 3. HUMAN steps pending (relay in chat)
1. Gitea, the repository, Actions: the run "Build Kvitt images" triggered by the `9231a1d` push: did the step "Log in to Gitea Container Registry" pass (yes/no), and did the whole run succeed (yes/no)? If the step "Tag and Push Version" failed with a permission error: may `write:repository` be added to `kvitt_cicd` (yes/no)? 1. Gitea, the repository, Actions, the latest run of "Build Kvitt images": open the step "Diagnose secrets" and paste its output lines in the chat (it prints only the lengths of the secrets and the job token, never their values, plus the repository, actor, event, ref and server URL). Also: did "Log in to Gitea Container Registry" pass (yes/no)? If "Tag and Push Version" failed with a permission error: may `write:repository` be added to `kvitt_cicd` (yes/no)?
2. The DuckDNS name for Kvitt: tell the orchestrator once it exists. An agent then puts it into `infra/pi/.env` as `KVITT_PUBLIC_HOST` and into `android/local.properties` as `kvitt.publicHost` on the owner's word (D-120, D-121). 2. NPM proxy host for `kvitt.suorra.duckdns.org`, per `infra/README.md` "Pi: what the owner does" step 6 (the owner said he will set it up now; Domain Names `kvitt.suorra.duckdns.org`, Forward Hostname / IP the Kvitt Pi's LAN address, Forward Port `8080`, SSL tab with a new Let's Encrypt certificate, the Advanced tab block of SPEC §10.5.2). First check that `kvitt.suorra.duckdns.org` resolves to the home's public address (the same as `icehack.asuscomm.com`). Expected: the Let's Encrypt request succeeds and NPM lists the proxy host with its certificate. Then the router (step 7): forward external TCP 8443 to the Home Assistant Pi's TCP 443. `/health` through NPM answers only once the Kvitt Pi runs (item 3).
3. Phone checks: done. About ("… (schema 3)") confirmed and the release app opens confirmed (2026-10-19). Release pairing is deferred to production: release builds require `https` (AC-2.8), so the release app pairs only once the Pi runs behind NPM. 3. Pi bundle, when ready: fill the remaining `CHANGE_ME` values in `infra/pi/.env` (the Kvitt Pi's address `KVITT_PI_HOST`, `HA_HOST`, `KVITT_TAG`; `KVITT_PUBLIC_HOST` is being filled by an agent), copy the bundle to the Kvitt Pi and run the nine steps of `infra/README.md` "Pi: what the owner does", relayed in chat when the owner moves over.
4. Pi checklist, later: deferred by the owner ("when it makes sense to move over to the Pi"). The nine steps of `infra/README.md` "Pi: what the owner does" (D-120: NPM proxy host at step 6, router forward 8443 → HA Pi 443 at step 7) are relayed in chat when the owner moves over. 4. Release app pairing, after item 3: release builds require `https` (AC-2.8), so the release app pairs only once the Pi runs behind NPM; a release APK built by `infra/publish-apk.sh` now targets `https://kvitt.suorra.duckdns.org:8443`.
## 4. Owner questions (asked, unanswered) ## 4. Owner questions (asked, unanswered)
- Kvitt's host name (`KVITT_PUBLIC_HOST`, a DuckDNS name being set up; §3 item 2).
- `write:repository` for `kvitt_cicd`, only if the workflow's tag push fails (§3 item 1).
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". - 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`. - 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? - State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built?
- `write:repository` for `kvitt_cicd`, 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).
## 5. Paused (waiting on something outside the repo) ## 5. Paused (waiting on something outside the repo)
- Nothing. - Nothing.
## 6. Numbering ## 6. Numbering
- Decisions: D-001 to D-121 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-122**. - Decisions: D-001 to D-123 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-124**.
- Spec version: v0.21 written; next **v0.22**. - Spec version: v0.22 written; next **v0.23**.
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.21. - 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.22.
- Sync protocol **1**, push payload **1**. - Sync protocol **1**, push payload **1**.
## 7. How the work is run ## 7. How the work is run
+4 -2
View File
@@ -124,5 +124,7 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| 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; 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-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-119 | 2026-10-19 | APK download rate limit. E-6 `GET /apk/files/{file_name}` is limited to 10 requests per minute per client IP in its own fixed window (`APK_DOWNLOAD_LIMIT` in `src/api/rate_limit.rs`), separate from and not counted against the 60 per minute of the other unauthenticated endpoints; the client IP as in D-032 (peer, or the last `X-Forwarded-For` entry from the trusted proxy); refusals are 429 `rate_limited` with `Retry-After`; every request counts, a 404 included; in memory, pruned like the other windows. E-5 `GET /apk/latest` and E-4 `GET /i/{code}` stay on the 60 per minute. Landed `96e4cbd` (unit test and an integration test in `tests/core_api/rate_limit.rs`). Owner: "APK download limit assumed is OK". | Security review L-3: each E-6 answer can be a ~10 MB APK, so 60 per minute per IP let one client pull about 600 MB a minute from the Pi; E-5 and E-4 answers are small and the phone checks E-5 at most every 6 hours (AC-16.2). | SPEC §8.6, §10.6, §14.3.4 (E-6), §11.4 |
| D-120 | 2026-10-19 | TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (`infra/cert-sync/**`, `infra/pi/Caddyfile`, images, volumes `certs` and `caddy_data`, secrets `ha_ssh_key` and `ha_known_hosts`, `.env` keys `HA_PORT`, `HA_USER`, `HA_CERT_DIR`). (1) Kvitt gets its own public host name `KVITT_PUBLIC_HOST` (owner-chosen, recorded in HANDOFF; a DuckDNS name is being set up), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects by name. `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`. A path prefix on another name is not supported. (2) The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues the name's Let's Encrypt certificate via HTTP-01 on port 80. (3) The Kvitt Pi runs `server` (publishes 8080 on all LAN interfaces, hardening unchanged) and `backup` (unchanged, D-105). NPM forwards plain HTTP over the LAN (accepted by the owner). (4) `KVITT_TRUSTED_PROXY=${HA_HOST}`, NPM's LAN IPv4 address, checked by `install.sh`; the server is unchanged and takes the last `X-Forwarded-For` entry from that peer. (5) The NPM proxy-host settings and the Custom Nginx Configuration of §10.5.2 carry over D-117's headers and log redaction: headers at server level with the server's copies hidden; its own `location /` without `add_header` (NPM's default adds `X-Served-By`, which makes nginx drop server-level headers); `location ^~ /i/` with `access_log off` and `error_log /dev/null crit` because NPM's access log writes `$request_uri` with the query string; `client_max_body_size 6m`; `proxy_read_timeout 60s`; HSTS on (NPM's 2 years). (6) Health checks: `http://localhost:8080/health` on the Pi, then through NPM (warning only); `install.sh` removes the old stack's leftovers. (7) Gitea login: the secret is interpolated in the `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" \| docker login …`), the owner's proven form, because the env-based form came through empty on act_runner 0.6.1 with a user-level secret; this reverses L-8's 'never expanded inside a shell line'; act_runner masks the value. Revises D-103 (bundle files), D-104 (images, Caddy pin, cert-sync packages, trigger path), D-106 (cert-sync part), D-117 parts 1, 2, 3, 7, 8, D-023's cert-sync secrets. Verified on the Mac 2026-10-19: nginx -t against NPM v2.16.0 templates; rehearsal server-behind-nginx 21 checks passed (headers once each, HSTS, http→https 301, 413 at 7 MB, rate limit per forwarded client, forged X-Forwarded-For contained, invite code/name/`/i/` absent from nginx logs including on 502). | Owner 2026-10-19: NPM already manages Let's Encrypt; the HA name is taken; 8443 → NPM 443 confirmed. | SPEC §8.5, §8.7, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.7, §10.10, §11.2, §14.7 | | D-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`. A path prefix on another name is not supported. (2) The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues the name's Let's Encrypt certificate via HTTP-01 on port 80. (3) The Kvitt Pi runs `server` (publishes 8080 on all LAN interfaces, hardening unchanged) and `backup` (unchanged, D-105). NPM forwards plain HTTP over the LAN (accepted by the owner). (4) `KVITT_TRUSTED_PROXY=${HA_HOST}`, NPM's LAN IPv4 address, checked by `install.sh`; the server is unchanged and takes the last `X-Forwarded-For` entry from that peer (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-121 | 2026-10-19 | The Android app's public host is a build property. `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; never resolves, so a release built without it cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, `/i/`, `autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://<host>:8443` (`link/ReleaseServer.kt`). `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` for S-1's prefill and its non-default-server check. Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (follow-up agent running). The pinned-root config's domain (D-082) is still the old literal and must follow the property if that fallback is ever prepared. Landed `d090c6c`. | D-120 gave Kvitt its own host name; it was hard-coded in three places. | SPEC AC-2.6, AC-2.7, §8.5, §10.5.3, §10.6, §13.3 S-1, §12 OQ-6; `android/README.md` | | D-121 | 2026-10-19 | The Android app's public host is a build property. `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; never resolves, so a release built without it cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, `/i/`, `autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://<host>:8443` (`link/ReleaseServer.kt`). `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` for S-1's prefill and its non-default-server check. Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` (~~follow-up agent running~~ 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`: `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`, 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` 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`. `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) |
+21 -17
View File
@@ -165,10 +165,10 @@ Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi
| Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) | | Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) |
| Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session | | Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session |
| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `<KVITT_PUBLIC_HOST>` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `<KVITT_PI_HOST>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) | | Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `<KVITT_PUBLIC_HOST>` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `<KVITT_PI_HOST>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) |
| Router (ASUS) | External TCP 8443 to the Home Assistant Pi's 443 (NPM); external 80 already reaches NPM for Let's Encrypt | The owner (step 7) | | Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 7) |
Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the
only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}`) and only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (default port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
`backup`; volumes `data`, `backups`; network `kvitt`; every service `restart: unless-stopped`, json-file logs `backup`; volumes `data`, `backups`; network `kvitt`; every service `restart: unless-stopped`, json-file logs
10 MB x 3, `TZ=Europe/Stockholm`. Images are `${KVITT_REGISTRY}/kvitt-server|kvitt-backup:${KVITT_TAG}` with 10 MB x 3, `TZ=Europe/Stockholm`. Images are `${KVITT_REGISTRY}/kvitt-server|kvitt-backup:${KVITT_TAG}` with
`KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by `KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
@@ -385,7 +385,7 @@ starts and waits. If an earlier bundle with Caddy and cert-sync was installed, i
`kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with `kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with
`server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, the line `GET http://localhost:8080/health: {"status":"ok",...}`, `server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, the line `GET http://localhost:8080/health: {"status":"ok",...}`,
then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or
`GET https://<KVITT_PUBLIC_HOST>:443/health (via NPM at <HA_HOST>): {"status":"ok",...}`, then "Kvitt is running.". `GET https://<KVITT_PUBLIC_HOST>/health (via NPM at <HA_HOST>:443): {"status":"ok",...}`, then "Kvitt is running.".
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-running Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-running
`install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`. `install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`.
@@ -396,7 +396,8 @@ HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two P
sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as
`icehack.asuscomm.com`). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already `icehack.asuscomm.com`). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already
works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of `icehack.asuscomm.com` works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of `icehack.asuscomm.com`
(for example `/kvitt/`) is not supported. (for example `/kvitt/`) is not supported. NPM listens on the default HTTPS port 443 for every proxy host and
picks the host by the name the client asks for, so Kvitt and Home Assistant share that port (D-124).
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts, Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts,
Add Proxy Host. Add Proxy Host.
@@ -509,13 +510,14 @@ the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it dow
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the 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. router's forward keeps the source address), so their `X-Real-IP` is ignored.
### 7. Router forward ### 7. Router: no new rule
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name `Kvitt`, Protocol TCP, External No new rule (D-124): external TCP 443 already reaches the Home Assistant Pi (NPM) for Home Assistant, and NPM picks
Port `8443`, Internal Port `443` (NPM's HTTPS port; if the add-on's Network settings map `443/tcp` to another host Kvitt's proxy host by name on the same port. Verify it exists: ASUS router web UI, WAN, Virtual Server / Port
port, use that one), Internal IP Address `<HA_HOST>`; Apply. No rule forwards to the Kvitt Pi (remove one to Forwarding: the list has a TCP rule with External Port `443` and Internal IP Address `<HA_HOST>` (yes/no). Nothing is
`<KVITT_PI_HOST>` if you made it for the Caddy version). Then on the phone with Wi-Fi off, open forwarded to the Kvitt Pi: remove a rule to `<KVITT_PI_HOST>` if you made one for the Caddy version, and a rule with
`https://<KVITT_PUBLIC_HOST>:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no). External Port `8443` if you made one for the earlier NPM version (D-120). Then on the phone with Wi-Fi off, open
`https://<KVITT_PUBLIC_HOST>/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
### 8. First backup and bootstrap ### 8. First backup and bootstrap
@@ -526,9 +528,10 @@ On the Pi:
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry /opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
``` ```
The invite link starts with `https://<KVITT_PUBLIC_HOST>:8443/i/`. The release app opens such links and connects to The invite link starts with `https://<KVITT_PUBLIC_HOST>/i/` (no port). The release app opens such links and connects to
that host only once an Android build carries the new host name (SPEC §8.5; until then the app knows that host only once its Android build carries the host name (`kvitt.publicHost`, D-121) and the default port 443
`icehack.asuscomm.com`): tell the orchestrator before pairing a release build. (D-124; builds made for port 8443 do not match a link without a port): tell the orchestrator before pairing a
release build.
### 9. Deploying a new version ### 9. Deploying a new version
@@ -552,8 +555,8 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
### Fallback (SPEC §10.5.3): when NPM is down ### Fallback (SPEC §10.5.3): when NPM is down
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over * NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
`https://<KVITT_PUBLIC_HOST>:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and `https://<KVITT_PUBLIC_HOST>` (that path goes through NPM too); Home Assistant is then unreachable from outside as
nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back. well (same NPM, same port 443). 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.
* On the home Wi-Fi only **debug builds** can reach the server directly, at `http://<KVITT_PI_HOST>:8080` (AC-2.8 * 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 allows `http://` to private LAN addresses in debug builds only). **Release builds require HTTPS with a publicly
trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A
@@ -580,12 +583,13 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) | | Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) |
| Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept | | Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
| Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` | | Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` |
| Client IPs are real (once, after the router forward) | Open `https://<KVITT_PUBLIC_HOST>:8443/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator | | Client IPs are real (once, after step 7) | Open `https://<KVITT_PUBLIC_HOST>/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
| Restore | `infra/RESTORE.md` | | Restore | `infra/RESTORE.md` |
**APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and **APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and
about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches
`/apk/files/` over and over (NPM's access log), remove the router forward (step 7) until it stops. `/apk/files/` over and over (NPM's access log), disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's
menu, Disable) until it stops; the router's 443 forward also serves Home Assistant, so leave it in place.
### Owner decisions (open) ### Owner decisions (open)
+3 -3
View File
@@ -10,9 +10,9 @@ KVITT_REGISTRY=gitea.lan/jacobh2
# or the short git SHA that infra/build-images.sh printed. # or the short git SHA that infra/build-images.sh printed.
KVITT_TAG=CHANGE_ME_TAG KVITT_TAG=CHANGE_ME_TAG
# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address. # Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address.
# NPM holds its Let's Encrypt certificate; the router forwards external 8443 to NPM's 443. Invite links and the # NPM holds its Let's Encrypt certificate and serves it on the default HTTPS port 443, which the router already
# landing page use https://<KVITT_PUBLIC_HOST>:8443 (KVITT_PUBLIC_URL). Not icehack.asuscomm.com: that name is # forwards to NPM for Home Assistant (D-124). Invite links and the landing page use https://<KVITT_PUBLIC_HOST>
# NPM's proxy host for Home Assistant. # (KVITT_PUBLIC_URL, no port). Not icehack.asuscomm.com: that name is NPM's proxy host for Home Assistant.
KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST
# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port # LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port
# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the # 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the
+1 -1
View File
@@ -30,7 +30,7 @@ services:
environment: environment:
TZ: Europe/Stockholm TZ: Europe/Stockholm
KVITT_DATA_DIR: /data KVITT_DATA_DIR: /data
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}:8443 KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}
# NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry # NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it). # is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it).
KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env} KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env}
+2 -2
View File
@@ -166,6 +166,6 @@ compose ps
print_health || ok=0 print_health || ok=0
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above" (( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
if ! print_proxy_health; then if ! print_proxy_health; then
say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 7)." say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 6)."
fi fi
say "Kvitt is running. Next: README step 7 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)." say "Kvitt is running. Next: README step 6 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."
+5 -4
View File
@@ -6,7 +6,8 @@
KVITT_DIR=${KVITT_DIR:-/opt/kvitt} KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120). # The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120).
SERVER_URL=http://localhost:8080 SERVER_URL=http://localhost:8080
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi (the router forwards external 8443 to it, D-120). # Nginx Proxy Manager's HTTPS port on the Home Assistant Pi, the default HTTPS port that Kvitt's public URL uses
# (the router's existing external 443 forward reaches it, D-124).
NPM_HTTPS_PORT=443 NPM_HTTPS_PORT=443
say() { printf '%s\n' "$*"; } say() { printf '%s\n' "$*"; }
@@ -76,11 +77,11 @@ print_proxy_health() {
local out ha_host host local out ha_host host
ha_host=$(env_value HA_HOST) ha_host=$(env_value HA_HOST)
host=$(env_value KVITT_PUBLIC_HOST) host=$(env_value KVITT_PUBLIC_HOST)
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host:$NPM_HTTPS_PORT/health" 2>&1); then if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host/health" 2>&1); then
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host): $out" say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT): $out"
return 0 return 0
fi fi
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host) failed: $out" say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT) failed: $out"
return 1 return 1
} }
+2 -2
View File
@@ -35,7 +35,7 @@ KVITT_PUBLIC_HOST="$(sed -n 's/^KVITT_PUBLIC_HOST=//p' "$env_file" | tail -n 1)"
[[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \ [[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \
|| { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; } || { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; }
echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST:8443" echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST"
PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \ PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \
"-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST" "-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST"
@@ -94,7 +94,7 @@ done | sort -n -r | tail -n +6 | while read -r old; do
done done
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json." echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
echo "Built for the release server https://$KVITT_PUBLIC_HOST:8443 (KVITT_PUBLIC_HOST in infra/pi/.env)." echo "Built for the release server https://$KVITT_PUBLIC_HOST (KVITT_PUBLIC_HOST in infra/pi/.env)."
echo "Copy to the Pi and install (infra/README.md, Publishing an APK):" echo "Copy to the Pi and install (infra/README.md, Publishing an APK):"
echo " scp infra/pi/out/apk/latest.json infra/pi/out/apk/$file_name infra/pi/out/apk/kvitt-$version_code.mapping.txt <ssh user>@<Kvitt Pi address>:/opt/kvitt/apk/" echo " scp infra/pi/out/apk/latest.json infra/pi/out/apk/$file_name infra/pi/out/apk/kvitt-$version_code.mapping.txt <ssh user>@<Kvitt Pi address>:/opt/kvitt/apk/"
echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh" echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh"
+2 -2
View File
@@ -2,7 +2,7 @@
One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration). One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration).
Dependencies follow SPEC §11.1 and §11.2 (v0.21). Status as of 2026-10-19: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`06f48ef`), M2b landed (`c277ee3`), M3 landed (`7869ec4`), M5 landed (`8f0e156`), M4 landed (`15056dc`), M9 landed (`50339ae`); SPEC v0.7 landed (`86e08a1`); M12a landed (`aa765bb`); SPEC v0.8 landed (`d39eb2c`); M12c landed (`b09f322`); M12b landed (`6eaccec`); U1 and U2 landed (`6da6b1b`, owner approved); SPEC v0.9 landed (`e676812`); M12d landed (`557af7e`); M6 landed (`7730a31`); SPEC v0.10 landed (`9ee67d4`); M7a landed (`1ea752c`), M7e (`ea7d494`), M7d (`7380e50`), M7b (`0e1d99d`), M7c (`0b140aa`), M7f (`c4ab35f`); M6b `:core:design` fixes landed (`b24c8b5`); SPEC v0.11 landed (`f4dc10c`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`3dda414`, no prompt file; brief by the orchestrator); M8 landed (`cb832a7`, `3d9eea8`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`8c60715`, `b7b7483`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`51d6ff8`); the Vico removal (`a312758`), the FCM `fid` target (`f56dcbe`) and the Pi image arm64 check (`3e6c337`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`328c466`, D-097) with the `fid` follow-up (`6eb3f85`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`75b549f`, `a0c25b0`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`c30a49e`) and M8d (`bacbd18`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`bfc3acb`); the M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`), M8f (`956908a`, D-108, instant navigation) and the chips cleanup (`1328afe`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108; after v0.17 the history rewrite and first push to `origin` (`18c527f`, all later hashes in this file are the rewritten ones), the infra overhaul (`2fac1ec` to `e70fe0d`), the server gap pass (`8660b7f`, D-113), the infra security fixes (`7fb86f5`, D-117) and M12f (`dc7a296` to `a0259ab`, D-109 to D-112, D-116; no prompt file, orchestrator brief) landed; SPEC v0.18 records them with the AC-coverage audit and the security review (`docs/validation/`); the server overhaul (`8103dae` to `b985ce9`, D-114, D-115) landed too and is recorded in v0.18; the Android overhaul (`e4e6e5d` to `c582363`, D-118; no prompt file, orchestrator brief) landed and was pushed to `origin` at `c582363`; SPEC v0.19 records it (`3ef2c15`); the APK download rate limit (`96e4cbd`, D-119; no prompt file, orchestrator brief) landed and was pushed to `origin`; SPEC v0.20 records it with the owner's security-review answers; `cargo audit` joined the server gate (`f1abb0d`); the NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`, D-120; no prompt file, orchestrator brief) replaced Caddy and cert-sync with an Nginx Proxy Manager proxy host on the Home Assistant Pi and reshaped the M2c-pi bundle (`PLAN-M2c-pi.md` carries a superseded-in-part note); the Android host property (`d090c6c`, D-121; no prompt file, orchestrator brief) landed; SPEC v0.21 records them. In flight: the D-121 follow-ups (`:data`'s default server URL, `infra/publish-apk.sh` passing `-Pkvitt.publicHost`). What remains is the owner's M2c-pi run on the Kvitt Pi (deferred by the owner until he moves over), then M10 and M11. Dependencies follow SPEC §11.1 and §11.2 (v0.22). Status as of 2026-10-19: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`06f48ef`), M2b landed (`c277ee3`), M3 landed (`7869ec4`), M5 landed (`8f0e156`), M4 landed (`15056dc`), M9 landed (`50339ae`); SPEC v0.7 landed (`86e08a1`); M12a landed (`aa765bb`); SPEC v0.8 landed (`d39eb2c`); M12c landed (`b09f322`); M12b landed (`6eaccec`); U1 and U2 landed (`6da6b1b`, owner approved); SPEC v0.9 landed (`e676812`); M12d landed (`557af7e`); M6 landed (`7730a31`); SPEC v0.10 landed (`9ee67d4`); M7a landed (`1ea752c`), M7e (`ea7d494`), M7d (`7380e50`), M7b (`0e1d99d`), M7c (`0b140aa`), M7f (`c4ab35f`); M6b `:core:design` fixes landed (`b24c8b5`); SPEC v0.11 landed (`f4dc10c`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`3dda414`, no prompt file; brief by the orchestrator); M8 landed (`cb832a7`, `3d9eea8`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`8c60715`, `b7b7483`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`51d6ff8`); the Vico removal (`a312758`), the FCM `fid` target (`f56dcbe`) and the Pi image arm64 check (`3e6c337`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`328c466`, D-097) with the `fid` follow-up (`6eb3f85`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`75b549f`, `a0c25b0`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`c30a49e`) and M8d (`bacbd18`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`bfc3acb`); the M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`), M8f (`956908a`, D-108, instant navigation) and the chips cleanup (`1328afe`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108; after v0.17 the history rewrite and first push to `origin` (`18c527f`, all later hashes in this file are the rewritten ones), the infra overhaul (`2fac1ec` to `e70fe0d`), the server gap pass (`8660b7f`, D-113), the infra security fixes (`7fb86f5`, D-117) and M12f (`dc7a296` to `a0259ab`, D-109 to D-112, D-116; no prompt file, orchestrator brief) landed; SPEC v0.18 records them with the AC-coverage audit and the security review (`docs/validation/`); the server overhaul (`8103dae` to `b985ce9`, D-114, D-115) landed too and is recorded in v0.18; the Android overhaul (`e4e6e5d` to `c582363`, D-118; no prompt file, orchestrator brief) landed and was pushed to `origin` at `c582363`; SPEC v0.19 records it (`3ef2c15`); the APK download rate limit (`96e4cbd`, D-119; no prompt file, orchestrator brief) landed and was pushed to `origin`; SPEC v0.20 records it with the owner's security-review answers; `cargo audit` joined the server gate (`f1abb0d`); the NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`, D-120; no prompt file, orchestrator brief) replaced Caddy and cert-sync with an Nginx Proxy Manager proxy host on the Home Assistant Pi and reshaped the M2c-pi bundle (`PLAN-M2c-pi.md` carries a superseded-in-part note); the Android host property (`d090c6c`, D-121; no prompt file, orchestrator brief) landed; SPEC v0.21 records them (`4bcff2c`); the D-121 follow-ups (`4a4e45f` S-22's release server, `4b1ae0c` `infra/publish-apk.sh` host, D-122), the server's host defaults and the OpenAPI `{host}` server (`c9abd24`, `e381e37`, D-123), the temporary workflow diagnostic step (`dd07ca4`) and the `install.sh` step number (`ac17b41`) landed (no prompt files, orchestrator briefs) and were pushed to `origin` at `ac17b41`; SPEC v0.22 records them with the owner's Kvitt host name `kvitt.suorra.duckdns.org` (HANDOFF). What remains is the owner's M2c-pi run on the Kvitt Pi (deferred by the owner until he moves over), then M10 and M11.
## Dependency order and waves ## Dependency order and waves
@@ -36,7 +36,7 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
| 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`cb832a7`, `3d9eea8`); on-phone test run 2026-10-18; M8b fixes `328c466`; M8c `75b549f`, `a0c25b0`, `7c453d8`; M8d `bacbd18`; M8e `a59abeb`; M8f `956908a`; chips cleanup `1328afe` (no prompt files; briefs by the orchestrator) | | 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`cb832a7`, `3d9eea8`); on-phone test run 2026-10-18; M8b fixes `328c466`; M8c `75b549f`, `a0c25b0`, `7c453d8`; M8d `bacbd18`; M8e `a59abeb`; M8f `956908a`; chips cleanup `1328afe` (no prompt files; briefs by the orchestrator) |
| 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`8c60715`, `b7b7483`) | | 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`8c60715`, `b7b7483`) |
| 4 | M12f Audit and security follow-ups (Android) | none (orchestrator brief) | M8f, AC-coverage audit, security review | done (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116) | | 4 | M12f Audit and security follow-ups (Android) | none (orchestrator brief) | M8f, AC-coverage audit, security review | done (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116) |
| 4 | Server gap pass; infra overhaul; infra security | none (orchestrator briefs) | M2c-pi bundle, security review | done (`8660b7f`, D-113; `2fac1ec` to `e70fe0d`; `7fb86f5`, D-117); server overhaul `8103dae` to `b985ce9` (D-114, D-115); Android overhaul `e4e6e5d` to `c582363` (D-118); APK download rate limit `96e4cbd` (D-119); `cargo audit` `f1abb0d`; NPM rework `4f69ae0` to `9231a1d` (D-120); Android host property `d090c6c` (D-121) | | 4 | Server gap pass; infra overhaul; infra security | none (orchestrator briefs) | M2c-pi bundle, security review | done (`8660b7f`, D-113; `2fac1ec` to `e70fe0d`; `7fb86f5`, D-117); server overhaul `8103dae` to `b985ce9` (D-114, D-115); Android overhaul `e4e6e5d` to `c582363` (D-118); APK download rate limit `96e4cbd` (D-119); `cargo audit` `f1abb0d`; NPM rework `4f69ae0` to `9231a1d` (D-120); Android host property `d090c6c` (D-121); its follow-ups `4a4e45f`, `4b1ae0c` (D-122); server host defaults `c9abd24`, `e381e37` (D-123) |
| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`2476667`, D-103 to D-106); reshaped by the NPM rework (`4f69ae0` to `9231a1d`, D-120; the plan is superseded where it names Caddy and cert-sync); the owner's run on the Pi remains, deferred by the owner | | 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`2476667`, D-103 to D-106); reshaped by the NPM rework (`4f69ae0` to `9231a1d`, D-120; the plan is superseded where it names Caddy and cert-sync); the owner's run on the Pi remains, deferred by the owner |
| 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none | | 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none |
| 7 | M11 Import history | `M11.md` | M9, M10 | none | | 7 | M11 Import history | `M11.md` | M9, M10 | none |
+2 -2
View File
@@ -30,10 +30,10 @@ Configuration is environment variables only (SPEC §10.4, exhaustive):
|---|---|---| |---|---|---|
| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite` (+ `-wal`, `-shm`), `pictures/`, `apk/`. Created if missing. | | `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite` (+ `-wal`, `-shm`), `pictures/`, `apk/`. Created if missing. |
| `KVITT_BIND` | `0.0.0.0:8080` | Listen address. | | `KVITT_BIND` | `0.0.0.0:8080` | Listen address. |
| `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Base URL in invite links and the landing page. | | `KVITT_PUBLIC_URL` | none: required | Base URL in invite links and the landing page, `https://<KVITT_PUBLIC_HOST>` on the Pi (set by compose). Every command refuses to start without it. |
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). | | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. |
| `KVITT_TRUSTED_PROXY` | `caddy` | Host allowed to set `X-Forwarded-For`; a name that does not resolve means no proxy. | | `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. |
| `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. | | `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. |
The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR` The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR`
@@ -20,8 +20,9 @@
//! on restart; there is no table. //! on restart; there is no table.
//! //!
//! The client IP is the TCP peer (axum `ConnectInfo<SocketAddr>`), or the last `X-Forwarded-For` entry when the peer //! The client IP is the TCP peer (axum `ConnectInfo<SocketAddr>`), or the last `X-Forwarded-For` entry when the peer
//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most //! is an address of `KVITT_TRUSTED_PROXY` (Nginx Proxy Manager, SPEC §8.5). The host name is resolved lazily and
//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without //! re-resolved at most once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted
//! (D-022), and so does the default `none` ([`NO_TRUSTED_PROXY`]), which is never looked up. Without
//! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket. //! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket.
use std::collections::HashMap; use std::collections::HashMap;
@@ -39,6 +40,7 @@ use tower::{Layer, Service};
use crate::audit::BoxFuture; use crate::audit::BoxFuture;
use crate::auth::{bearer_token, is_token_shaped, sha256_hex}; use crate::auth::{bearer_token, is_token_shaped, sha256_hex};
use crate::config::NO_TRUSTED_PROXY;
use crate::error::AppError; use crate::error::AppError;
use crate::time::Clock; use crate::time::Clock;
use crate::{AppState, lock}; use crate::{AppState, lock};
@@ -214,7 +216,7 @@ impl Limiter {
return addrs.clone(); return addrs.clone();
} }
let name = self.trusted_proxy.trim(); let name = self.trusted_proxy.trim();
let addrs: Vec<IpAddr> = if name.is_empty() { let addrs: Vec<IpAddr> = if name.is_empty() || name.eq_ignore_ascii_case(NO_TRUSTED_PROXY) {
Vec::new() Vec::new()
} else if let Ok(ip) = name.parse::<IpAddr>() { } else if let Ok(ip) = name.parse::<IpAddr>() {
vec![ip] vec![ip]
@@ -463,6 +465,46 @@ mod tests {
); );
} }
fn limiter_trusting(trusted_proxy: &str) -> Limiter {
let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap();
Limiter::new(Arc::new(FixedClock::new(now)), trusted_proxy.to_owned())
}
#[tokio::test]
async fn the_default_none_trusts_no_peer() {
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
for value in [crate::config::DEFAULT_TRUSTED_PROXY, " NONE ", ""] {
let limiter = limiter_trusting(value);
assert_eq!(
limiter.proxy_addrs().await,
Vec::<IpAddr>::new(),
"{value:?}"
);
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
}
#[tokio::test]
async fn an_unresolvable_name_trusts_no_peer() {
// `.invalid` never resolves (RFC 6761).
let limiter = limiter_trusting("proxy.kvitt.invalid");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
}
#[tokio::test]
async fn an_ip_literal_is_trusted() {
let limiter = limiter_trusting("192.168.1.20");
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
assert_eq!(
limiter.client_ip(Some(peer), Some(forwarded)).await,
forwarded
);
}
fn token_key(i: u32) -> Key { fn token_key(i: u32) -> Key {
Key::Token { Key::Token {
ip: IpAddr::V4(Ipv4Addr::LOCALHOST), ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
+56 -19
View File
@@ -9,14 +9,16 @@ use crate::error::AppError;
pub const DEFAULT_DATA_DIR: &str = "/data"; pub const DEFAULT_DATA_DIR: &str = "/data";
/// Default of `KVITT_BIND`. /// Default of `KVITT_BIND`.
pub const DEFAULT_BIND: &str = "0.0.0.0:8080"; pub const DEFAULT_BIND: &str = "0.0.0.0:8080";
/// Default of `KVITT_PUBLIC_URL`.
pub const DEFAULT_PUBLIC_URL: &str = "https://icehack.asuscomm.com:8443";
/// Default of `KVITT_FCM_CREDENTIALS`. /// Default of `KVITT_FCM_CREDENTIALS`.
pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json"; pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json";
/// Default of `KVITT_FCM_PROJECT_ID`. /// Default of `KVITT_FCM_PROJECT_ID`.
pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337"; pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337";
/// Default of `KVITT_TRUSTED_PROXY`. /// Default of `KVITT_TRUSTED_PROXY`: no peer is trusted to set `X-Forwarded-For` (the rate limiter never resolves
pub const DEFAULT_TRUSTED_PROXY: &str = "caddy"; /// this value). `KVITT_PUBLIC_URL` has no default: the Kvitt host name is owner-chosen (SPEC §10.5), so the server
/// refuses to start without it.
pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY;
/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space).
pub const NO_TRUSTED_PROXY: &str = "none";
/// Default of `RUST_LOG`. /// Default of `RUST_LOG`.
pub const DEFAULT_LOG_FILTER: &str = "info"; pub const DEFAULT_LOG_FILTER: &str = "info";
@@ -33,14 +35,15 @@ pub struct Config {
pub data_dir: PathBuf, pub data_dir: PathBuf,
/// `KVITT_BIND`: listen address. /// `KVITT_BIND`: listen address.
pub bind: SocketAddr, pub bind: SocketAddr,
/// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Stored without a trailing slash. /// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Required (no default). Stored without a
/// trailing slash.
pub public_url: String, pub public_url: String,
/// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing. /// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing.
pub fcm_credentials: PathBuf, pub fcm_credentials: PathBuf,
/// `KVITT_FCM_PROJECT_ID`. /// `KVITT_FCM_PROJECT_ID`.
pub fcm_project_id: String, pub fcm_project_id: String,
/// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the /// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the
/// rate limiter; an unresolvable name means "no proxy". /// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy".
pub trusted_proxy: String, pub trusted_proxy: String,
} }
@@ -51,13 +54,10 @@ impl Config {
} }
/// Reads the variables through `lookup` (testable without touching the process environment). Empty values count /// Reads the variables through `lookup` (testable without touching the process environment). Empty values count
/// as unset. /// as unset. A missing `KVITT_PUBLIC_URL` is an error.
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AppError> { pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AppError> {
let get = |key: &str, default: &str| { let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty());
lookup(key) let get = |key: &str, default: &str| set(key).unwrap_or_else(|| default.to_owned());
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| default.to_owned())
};
let bind_text = get("KVITT_BIND", DEFAULT_BIND); let bind_text = get("KVITT_BIND", DEFAULT_BIND);
let bind: SocketAddr = bind_text.parse().map_err(|e| { let bind: SocketAddr = bind_text.parse().map_err(|e| {
AppError::new( AppError::new(
@@ -65,12 +65,17 @@ impl Config {
format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"), format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"),
) )
})?; })?;
let public_url = set("KVITT_PUBLIC_URL").ok_or_else(|| {
AppError::new(
crate::error::ErrorCode::Internal,
"KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \
https://<KVITT_PUBLIC_HOST> (SPEC §10.4)",
)
})?;
Ok(Self { Ok(Self {
data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)), data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)),
bind, bind,
public_url: get("KVITT_PUBLIC_URL", DEFAULT_PUBLIC_URL) public_url: public_url.trim().trim_end_matches('/').to_owned(),
.trim_end_matches('/')
.to_owned(),
fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)), fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)),
fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID), fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID),
trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY), trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY),
@@ -97,18 +102,25 @@ impl Config {
mod tests { mod tests {
use super::*; use super::*;
const PUBLIC_URL: &str = "https://kvitt.example.invalid";
#[test] #[test]
fn defaults_when_unset_or_empty() { fn defaults_when_unset_or_empty() {
let config = Config::from_lookup(|k| (k == "KVITT_BIND").then(String::new)).unwrap(); let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" | "KVITT_TRUSTED_PROXY" => Some(String::new()),
_ => None,
})
.unwrap();
assert_eq!(config.data_dir, PathBuf::from("/data")); assert_eq!(config.data_dir, PathBuf::from("/data"));
assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap()); assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap());
assert_eq!(config.public_url, "https://icehack.asuscomm.com:8443"); assert_eq!(config.public_url, PUBLIC_URL);
assert_eq!( assert_eq!(
config.fcm_credentials, config.fcm_credentials,
PathBuf::from("/secrets/fcm-service-account.json") PathBuf::from("/secrets/fcm-service-account.json")
); );
assert_eq!(config.fcm_project_id, "kvitt-1337"); assert_eq!(config.fcm_project_id, "kvitt-1337");
assert_eq!(config.trusted_proxy, "caddy"); assert_eq!(config.trusted_proxy, "none");
assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite")); assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite"));
} }
@@ -118,6 +130,7 @@ mod tests {
"KVITT_DATA_DIR" => Some("/tmp/kvitt".into()), "KVITT_DATA_DIR" => Some("/tmp/kvitt".into()),
"KVITT_BIND" => Some("127.0.0.1:9000".into()), "KVITT_BIND" => Some("127.0.0.1:9000".into()),
"KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()), "KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()),
"KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()),
_ => None, _ => None,
}) })
.unwrap(); .unwrap();
@@ -127,10 +140,34 @@ mod tests {
); );
assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap()); assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap());
assert_eq!(config.public_url, "http://192.168.1.10:8080"); assert_eq!(config.public_url, "http://192.168.1.10:8080");
assert_eq!(config.trusted_proxy, "192.168.1.20");
}
#[test]
fn public_url_is_required() {
for value in [None, Some(""), Some(" ")] {
let error = Config::from_lookup(|k| {
(k == "KVITT_PUBLIC_URL")
.then_some(value)
.flatten()
.map(str::to_owned)
})
.unwrap_err();
assert!(
error.message.contains("KVITT_PUBLIC_URL"),
"{}",
error.message
);
}
} }
#[test] #[test]
fn bad_bind_is_an_error() { fn bad_bind_is_an_error() {
assert!(Config::from_lookup(|k| (k == "KVITT_BIND").then(|| "nope".into())).is_err()); let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" => Some("nope".into()),
_ => None,
});
assert!(config.is_err());
} }
} }
+21 -4
View File
@@ -23,7 +23,7 @@ use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn};
use utoipa::openapi::response::{Response, ResponseBuilder}; use utoipa::openapi::response::{Response, ResponseBuilder};
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type}; use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme}; use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme};
use utoipa::openapi::server::Server; use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
use utoipa::openapi::{Content, Ref, RefOr, Required}; use utoipa::openapi::{Content, Ref, RefOr, Required};
use utoipa::{Modify, OpenApi}; use utoipa::{Modify, OpenApi};
@@ -35,8 +35,15 @@ const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household
timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \ timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \
strings. Common rules: SPEC §14.3.1."; strings. Common rules: SPEC §14.3.1.";
/// The production base URL (SPEC §7). /// The production base URL (SPEC §7, §14.3): the host is the server variable [`HOST_VARIABLE`], because the Kvitt
pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443"; /// host name is owner-chosen (SPEC §10.5).
pub const PUBLIC_SERVER_URL: &str = "https://{host}";
/// The server variable in [`PUBLIC_SERVER_URL`].
pub const HOST_VARIABLE: &str = "host";
/// The default of [`HOST_VARIABLE`]: a reserved name that never resolves (RFC 6761), not a real host.
pub const HOST_VARIABLE_DEFAULT: &str = "kvitt.example.invalid";
/// The description of [`HOST_VARIABLE`].
const HOST_VARIABLE_DESCRIPTION: &str = "the owner-chosen Kvitt host name, SPEC §10.5";
const JSON: &str = api::JSON_CONTENT_TYPE; const JSON: &str = api::JSON_CONTENT_TYPE;
const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version"; const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
@@ -191,7 +198,17 @@ impl Modify for CommonRules {
openapi.info.description = Some(INFO_DESCRIPTION.to_owned()); openapi.info.description = Some(INFO_DESCRIPTION.to_owned());
openapi.info.license = None; openapi.info.license = None;
openapi.info.contact = None; openapi.info.contact = None;
openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]); openapi.servers = Some(vec![
ServerBuilder::new()
.url(PUBLIC_SERVER_URL)
.parameter(
HOST_VARIABLE,
ServerVariableBuilder::new()
.default_value(HOST_VARIABLE_DEFAULT)
.description(Some(HOST_VARIABLE_DESCRIPTION)),
)
.build(),
]);
openapi.security = Some(vec![SecurityRequirement::new( openapi.security = Some(vec![SecurityRequirement::new(
"device", "device",
Vec::<String>::new(), Vec::<String>::new(),
@@ -22,6 +22,8 @@ use sqlx::SqliteConnection;
use tower::ServiceExt; use tower::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
/// `KVITT_PUBLIC_URL` of the harness, on the reserved `.invalid` name of the examples (E-7, E-12).
pub const PUBLIC_URL: &str = "https://kvitt.example.invalid";
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001"; pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001";
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002"; pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002";
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001"; pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001";
@@ -151,6 +153,7 @@ impl TestApp {
let path = dir.0.to_string_lossy().into_owned(); let path = dir.0.to_string_lossy().into_owned();
let config = Config::from_lookup(|k| match k { let config = Config::from_lookup(|k| match k {
"KVITT_DATA_DIR" => Some(path.clone()), "KVITT_DATA_DIR" => Some(path.clone()),
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.to_owned()),
// No proxy is trusted unless a test says so. // No proxy is trusted unless a test says so.
"KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()), "KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()),
_ => None, _ => None,
@@ -12,7 +12,7 @@ fn run(dir: &std::path::Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_kvitt-server")) Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.args(args) .args(args)
.env("KVITT_DATA_DIR", dir) .env("KVITT_DATA_DIR", dir)
.env("KVITT_PUBLIC_URL", "https://kvitt.example:8443") .env("KVITT_PUBLIC_URL", "https://kvitt.example")
.env("RUST_LOG", "warn") .env("RUST_LOG", "warn")
.output() .output()
.unwrap() .unwrap()
@@ -26,7 +26,7 @@ fn printed_code(output: &Output) -> String {
.find_map(|l| l.strip_prefix("Link:")) .find_map(|l| l.strip_prefix("Link:"))
.unwrap() .unwrap()
.trim(); .trim();
let code = link.strip_prefix("https://kvitt.example:8443/i/").unwrap(); let code = link.strip_prefix("https://kvitt.example/i/").unwrap();
assert_eq!(code.len(), 12); assert_eq!(code.len(), 12);
let display = stdout let display = stdout
.lines() .lines()
@@ -39,6 +39,30 @@ fn printed_code(output: &Output) -> String {
code.to_owned() code.to_owned()
} }
#[test]
fn every_command_refuses_to_start_without_a_public_url() {
// SPEC §10.4: KVITT_PUBLIC_URL has no built-in host; compose always sets it.
let dir = TempDir::new();
for args in [&["serve"][..], &["bootstrap"], &["migrate"]] {
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.args(args)
.env("KVITT_DATA_DIR", &dir.0)
.env_remove("KVITT_PUBLIC_URL")
.env("RUST_LOG", "warn")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{args:?}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.starts_with("configuration: KVITT_PUBLIC_URL is not set"),
"{args:?}: {stderr}"
);
assert!(output.stdout.is_empty(), "{args:?}");
}
// Nothing was created: the server stopped before opening the database.
assert!(!dir.0.join("kvitt.sqlite").exists());
}
#[tokio::test] #[tokio::test]
async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses() { async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses() {
let dir = TempDir::new(); let dir = TempDir::new();
@@ -107,7 +131,7 @@ fn printed_partner_code(output: &Output, from: &str) -> String {
.find_map(|l| l.strip_prefix("Link:")) .find_map(|l| l.strip_prefix("Link:"))
.unwrap() .unwrap()
.trim(); .trim();
let rest = link.strip_prefix("https://kvitt.example:8443/i/").unwrap(); let rest = link.strip_prefix("https://kvitt.example/i/").unwrap();
let (code, query) = rest.split_once('?').unwrap(); let (code, query) = rest.split_once('?').unwrap();
assert_eq!(query, format!("from={from}")); assert_eq!(query, format!("from={from}"));
assert_eq!(code.len(), 12); assert_eq!(code.len(), 12);
@@ -219,9 +219,7 @@ async fn e12_partner_invite_matches_the_example() {
assert_eq!(code.len(), 12); assert_eq!(code.len(), 12);
assert_eq!( assert_eq!(
reply.json["link"], reply.json["link"],
json!(format!( json!(format!("{PUBLIC_URL}/i/{code}?from=Jacob"))
"https://icehack.asuscomm.com:8443/i/{code}?from=Jacob"
))
); );
assert_eq!(reply.json["created_at"], NOW); assert_eq!(reply.json["created_at"], NOW);
assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z"); assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z");
@@ -34,9 +34,9 @@ async fn e4_english_by_default_with_both_links_and_the_grouped_code() {
assert!(html.contains(">Open in Kvitt</a>")); assert!(html.contains(">Open in Kvitt</a>"));
assert!(html.contains("7K3M-QX9D-2HPA")); assert!(html.contains("7K3M-QX9D-2HPA"));
assert!(html.contains( assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;code=7K3MQX9D2HPA\"" "href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid&amp;code=7K3MQX9D2HPA\""
)); ));
assert!(html.contains("href=\"https://icehack.asuscomm.com:8443/apk/files/kvitt-412.apk\"")); assert!(html.contains("href=\"https://kvitt.example.invalid/apk/files/kvitt-412.apk\""));
} }
#[tokio::test] #[tokio::test]
@@ -90,7 +90,7 @@ async fn e4_passes_the_admin_name_of_an_e12_link_into_the_app_link_only() {
assert_eq!(reply.status, 200); assert_eq!(reply.status, 200);
let html = reply.text(); let html = reply.text();
assert!(html.contains( assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\"" "href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
), "{html}"); ), "{html}");
// The name is not shown on the page itself. // The name is not shown on the page itself.
assert!(!html.contains("Åsa")); assert!(!html.contains("Åsa"));
@@ -47,8 +47,10 @@ impl TestApp {
async fn migrated_to(target: Option<i64>) -> Self { async fn migrated_to(target: Option<i64>) -> Self {
let dir = TempDir::new(); let dir = TempDir::new();
let config = Config::from_lookup(|k| { let config = Config::from_lookup(|k| match k {
(k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned()) "KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()),
"KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid".to_owned()),
_ => None,
}) })
.unwrap(); .unwrap();
let pool = db::connect(&config.database_path()).await.unwrap(); let pool = db::connect(&config.database_path()).await.unwrap();
@@ -79,6 +79,7 @@ async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() {
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server")) let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.arg("rotate-epoch") .arg("rotate-epoch")
.env("KVITT_DATA_DIR", app.data_dir()) .env("KVITT_DATA_DIR", app.data_dir())
.env("KVITT_PUBLIC_URL", PUBLIC_URL)
.env("RUST_LOG", "warn") .env("RUST_LOG", "warn")
.output() .output()
.unwrap(); .unwrap();
@@ -96,6 +97,7 @@ fn binary(dir: &std::path::Path, bind: SocketAddr) -> Command {
command command
.env("KVITT_DATA_DIR", dir) .env("KVITT_DATA_DIR", dir)
.env("KVITT_BIND", bind.to_string()) .env("KVITT_BIND", bind.to_string())
.env("KVITT_PUBLIC_URL", PUBLIC_URL)
.env("KVITT_FCM_CREDENTIALS", dir.join("no-such-key.json")) .env("KVITT_FCM_CREDENTIALS", dir.join("no-such-key.json"))
.env("RUST_LOG", "warn"); .env("RUST_LOG", "warn");
command command
+1 -1
View File
@@ -1471,7 +1471,7 @@ def check_openapi():
lines = f.read().splitlines() lines = f.read().splitlines()
text = "\n".join(lines) text = "\n".join(lines)
head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M) head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M)
and re.search(r"^ version: '1'$", text, re.M) and "url: https://icehack.asuscomm.com:8443" in text) and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M))
report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3") report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3")
sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None
report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found") report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found")