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 atac17b41. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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_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_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 |
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -963,7 +963,7 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
|
||||
|
||||
| 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 | Forward Hostname / IP | `<KVITT_PI_HOST>` (the Kvitt Pi's LAN IPv4 address) |
|
||||
| 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.
|
||||
* 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
|
||||
{
|
||||
@@ -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`.
|
||||
* 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).
|
||||
@@ -1277,7 +1277,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
|
||||
### 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).
|
||||
* **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).
|
||||
@@ -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-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-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):
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
// D-122: serverUrl is the paired server's base URL; while none is stored, AppBuildInfo.releaseServerUrl.
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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" }
|
||||
```
|
||||
|
||||
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-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-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`.
|
||||
|
||||
@@ -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.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.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). |
|
||||
|
||||
Reference in New Issue
Block a user