SPEC v0.18: D-109 to D-117; validation reports; HANDOFF, prompts
Build Kvitt images / build (push) Failing after 1m24s
Build Kvitt images / build (push) Failing after 1m24s
- D-109 S-16 lost version read-only (Rejection.lost: RejectedVersion), D-110 S-22 versions from /health
(AboutState.schemaVersion), D-111 SettlementRepository nullable start/recompute, D-112 E-17 retry,
D-113 invite-partner and the push sent line, D-114 picture 16 MP / 64 MB and fcm_token 512 limits,
D-115 rate-limit token acceptance (revises D-032), D-116 private notifications (AC-13.9) and L-6 as
built (OQ-6), D-117 infra security (Caddy log redaction and headers, non-root Caddy, dev secret,
digest record, key form).
- §10.5.2 quotes infra/pi/Caddyfile at 7fb86f5; §10.4 invite-partner row; §11.2 M12f row; §11.3 server
gap pass, server overhaul, infra notes; new §11.4 validation reports; §14.7 server tree; strings.csv
562 rows; localeFilters, work-testing, harness fix, APK 10.0 MB.
- docs/validation/ac-coverage.md and security-review.md committed verbatim from the reports.
- Commit hashes in SPEC, decisions, HANDOFF and prompts/README remapped to the rewritten history (18c527f).
- design-spec: S-16 lost-version dialog, S-22 "(schema <n>)", the S-1 rationale mention dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
> Taken at `ed1b130` (main, 2026-10-18, SPEC v0.16; `956908a` after the history rewrite, so the hashes below are pre-rewrite); committed verbatim with SPEC v0.18. Its two S-16 and S-22 mismatches are fixed since (D-109, D-110; SPEC §11.4).
|
||||
|
||||
# AC coverage audit: is Kvitt feature complete against SPEC.md?
|
||||
|
||||
Audited 2026-10-18 at `main` `ed1b130` (SPEC v0.16), read-only: code and tests were read (`git grep`, file reads); no build, no test run, no phone. Scope: SPEC §4 (FR-1 to FR-20, every AC), §9 rules, §13.3 screen actions S-1 to S-22, §14.2.4 UI contract, §14.3.4 endpoints E-1 to E-33, §14.4 sync, §14.6 push, §10.4 server CLI. Aggregates only; no personal data.
|
||||
|
||||
Status meanings: **Done** = implemented in the real path (`:data`, features, `:app`, server crates) and demonstrated by a test, or by the recorded M8 phone check; **Partial** = implemented but a sub-clause or the §4 evidence rule is not met; **Missing** = not present; **Not verifiable without device** = only on-device or on-Pi behaviour (system UI) can prove it.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
| Status | Rows |
|
||||
|---|---|
|
||||
| Done | 260 |
|
||||
| Partial | 5 |
|
||||
| Missing | 1 |
|
||||
| Not verifiable without device | 4 |
|
||||
| **Total** | **270** |
|
||||
|
||||
Per part: server side (Part A, 98 rows): 95 Done, 2 Partial, 1 Missing. Android FR-2 to FR-12 and S-1 to S-10 (Part B, 100 rows): 96 Done, 1 Partial, 3 device-only. Android FR-9, FR-13 to FR-16, FR-19, S-11 to S-22, UI contract (Part C, 72 rows): 69 Done, 2 Partial, 1 device-only.
|
||||
|
||||
Further findings:
|
||||
- All 33 endpoints are routed (`server/crates/kvitt-server/src/router.rs:31-102`) and tested, and the OpenAPI equality test is in the server gate.
|
||||
- All six CLI subcommands of §10.4 exist.
|
||||
- Every S-1 to S-22 action reaches a real destination or repository call in `android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt`; none is wired to a no-op.
|
||||
- `:core:model` matches the §14.2.4 block exactly, the v0.13 additions included.
|
||||
|
||||
## 2. Verdict
|
||||
|
||||
**Feature complete in code: yes. Feature complete against SPEC §4's acceptance rule: no, not yet.**
|
||||
|
||||
Every functional requirement the app and server must implement is built. What remains falls into three groups:
|
||||
|
||||
**(a) Blocked on the Pi or the first release, which are ops milestones rather than code:**
|
||||
- AC-18.2 restore drill (Missing).
|
||||
- AC-18.1 nightly Drive backup observed on the Pi.
|
||||
- AC-20.1 latency under 100 ms on the Pi.
|
||||
- AC-16.3 the real PackageInstaller install.
|
||||
|
||||
All belong to M2c-pi or M10 by the milestone table.
|
||||
|
||||
**(b) Two spec mismatches in shipped behaviour:**
|
||||
- S-16 "open a rejected change (read-only)" opens the current editable row instead of the lost change.
|
||||
- S-22 reads the server version from a response header, not from `/health`.
|
||||
|
||||
**(c) Test-evidence gaps where the code reads correctly** (§9.2 effect line, AC-2.9, a few `:data` and sync-engine paths).
|
||||
|
||||
None of (b) or (c) blocks the code overhaul or the security review. The (a) items are the M10 gate itself.
|
||||
|
||||
Blockers for "feature complete":
|
||||
1. AC-18.2 restore drill report.
|
||||
2. S-16 read-only rejected-change view: implement it, or have a docs decision accept the summary line.
|
||||
3. S-22 server-version source: fix the code, or amend the spec.
|
||||
|
||||
The first release (M10) additionally needs AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones.
|
||||
|
||||
## 3. Prioritised gaps (Missing and Partial first, then device-only, then evidence and robustness)
|
||||
|
||||
1. **AC-18.2 Restore drill (Missing).** `docs/validation/` does not exist, and no drill has been run; the procedure is in `infra/RESTORE.md`. Smallest change: once the Pi stack runs, or earlier on the dev stack as a rehearsal, follow `infra/RESTORE.md`:
|
||||
- restore the latest backup;
|
||||
- run `kvitt-server rotate-epoch`;
|
||||
- let both phones sync and confirm the epoch recovery;
|
||||
- write `docs/validation/restore-drill.md` with a "Results (owner's phone)" section.
|
||||
|
||||
Owner: M10 (first release), as an ops and docs agent plus the owner's taps.
|
||||
2. **S-16 "open a rejected change (read-only)" (Partial).** Evidence: `android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt` (RejectionCard, around line 274). The tap opens the ordinary editable S-8 or S-10 for the entity id, which holds the server's winning row (or the "missing" state). The lost local version in `sync_rejections.rejected_payload` is never shown, so "what was lost" is only the one-line summary. Smallest change: a read-only sheet on S-16 that renders the rejected payload. That needs a `Rejection` field, for example a list of display fields, so it is a contract change (§14.8) across `:core:model`, `:data`, `:core:fakes` and `:feature:home`. The alternative is a docs decision that the summary plus opening the current row satisfies §13.3. Owner: an M12-style contract pass, or a docs agent.
|
||||
3. **S-22 server version source (Partial).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt:336-345` reads the `X-Kvitt-Server-Version` header of the last response (`data/.../api/KvittApi.kt:241`), so the value is empty until a request reaches the server after process start. §13.3 says "from `/health`". Smallest change: call E-1 once when S-22 opens, in `AboutRepositoryImpl` (`:data`, no contract change), or amend the §13.3 wording. Owner: `:data`, or a docs agent.
|
||||
4. **AC-18.1 Nightly Drive backup (Partial).** The code is complete: 03:30 cron in `infra/backup/Dockerfile`, `rclone copy --immutable` in `infra/backup/backup.sh`. It has never run on the Pi. Smallest change: after the M2c-pi bundle is deployed, list the Drive folder the next morning and record the result in the restore-drill report. Owner: M2c-pi, run by the owner, then M10.
|
||||
5. **AC-20.1 Health under 100 ms on the Pi (Partial).** Function and test are done (`tests/skeleton/routes.rs` health tests); the latency is not measured. Smallest change: a timing loop against `/health` on the Pi, with the aggregate written into `docs/validation/first-release.md`. Owner: M10.
|
||||
6. **§9.2 Effect line (Partial: evidence).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt:124` `effectFor` reads correctly, but no `:data` test covers its four cases; the screenshots render the fakes. Smallest change: one `RepositoriesTest` case with four expenses asserting `Lent`, `Borrowed`, `PaidForYourself` and `NotInvolved`. Owner: `:data` (code-overhaul phase).
|
||||
7. **AC-16.3 PackageInstaller and "Install unknown apps" on the device (device-only).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt:177-209`; the size and SHA-256 checks are unit-tested. Smallest change: verify during M10 with a published signed APK, one owner yes/no. Owner: M10.
|
||||
8. **AC-2.9 Notification permission after the first sync (device-only, with no unit test).** `android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt:60-66`. Smallest change: an `:app` unit test with a fake `SyncRepository`, plus an owner yes/no on the next fresh pairing. Owner: `:app`.
|
||||
9. **AC-4.3 and AC-4.6 Photo picker and offline cached pictures (device-only).** Smallest change: two owner yes/no taps (pick a picture in Profile; in airplane mode, the pictures still show). Owner: orchestrator HUMAN steps.
|
||||
10. **AC-2.6 Stale invite held while paired (note).** A link opened while paired is ignored on screen but kept in `AppViewModel.invite`, and it prefills S-1 if the phone later becomes unpaired or revoked. Smallest change: drop it in `AppViewModel.onLink` while the session is `Paired`, with a unit test. Owner: `:app`.
|
||||
11. **AC-13.7 Push-token robustness (note).** A failed E-17 PUT is logged and not retried until Firebase calls `onRegistered` again, so a device could stay without pushes. Smallest change: re-send the stored FID at each paired start or after the first successful sync. Owner: `:app` push or `:adapter:fcm`.
|
||||
12. **AC-19.1 Enforcement (note).** Nothing in the gate rejects a string literal in feature code; a grep finds none today. Smallest change: a literal check in `build-logic`'s `ArchitectureCheckTask`. Owner: build-logic.
|
||||
13. **Missing tests on existing code (not AC gaps):**
|
||||
- `SyncEngineTest`: the 429 retry, and the D-040 whole-request refusal (400, 413, 409, 422 lead to the pull plus `last_error_code`).
|
||||
- `:data`: `CategoryRepositoryImpl.reorder` (one PATCH per changed `sort_order`), and the recurring `toWire` splits per mode.
|
||||
- The WorkManager unique names and policies.
|
||||
- The server CLI: `healthcheck`, `migrate`, and the 10 s SIGTERM drain of `serve`.
|
||||
|
||||
Owners: `:data`, and a server plan (code-overhaul phase).
|
||||
14. **AC-5.1 Home gate without a partner (note).** `KvittApp.kt:236-240` has no test; S-3 itself has screenshots and ViewModel tests. Low priority.
|
||||
|
||||
For the code overhaul and security review, not AC items:
|
||||
- `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt:49-50` uses `checkNotNull`, which throws in main code when there is no session or no partner. The UI gate prevents it today.
|
||||
- HANDOFF's ops request for a partner-invite `kvitt-server` subcommand would need a spec change first, because §10.4's subcommand list is exhaustive.
|
||||
|
||||
## 4. TODO(spec), TODO and FIXME in sources
|
||||
|
||||
`git grep -n -E 'TODO|FIXME'` over `android/`, `server/`, `infra/` and `tools/`:
|
||||
|
||||
| file:line | text | note |
|
||||
|---|---|---|
|
||||
| `android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt:72` | `TODO(spec): not stated in category-suggestion.md` | CS-5 final tie-break (lower category id). SPEC §9.11 CS-5 now states it, so the marker is stale |
|
||||
| `tools/contracts-check.py:326` | `TODO(spec), category-suggestion.md does not say.` | Same tie rule, also stale |
|
||||
|
||||
There is no other TODO or FIXME in android, server, infra or tools. The `XXX` hits are mktemp templates (`infra/cert-sync/sync.sh:36`, `infra/pi/lib.sh:22`, `infra/publish-apk.sh:59`) and a doc comment (`server/crates/kvitt-server/src/api/invites.rs:68`).
|
||||
|
||||
## 5. Coverage tables
|
||||
|
||||
### Part A: server side (kvitt-server, kvitt-domain, kvitt-import, infra)
|
||||
|
||||
Read at `ed1b130`. Paths are relative to `server/crates/kvitt-server/` unless they start with `server/`, `infra/` or `docs/`. Test evidence is given as `file::test_fn`. No build was run; the evidence comes from reading the code and the tests. Every route of §14.3.4 is registered in `src/router.rs:31-102`, and `tests/skeleton/routes.rs::every_route_is_registered_and_implemented` checks this.
|
||||
|
||||
#### Endpoints (§14.3.4)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| E-1 | `GET /health`: status, versions, db check; 503 `degraded` | Done | src/router.rs:32, src/api/health.rs; skeleton/routes.rs::health_reports_ok_with_versions, ::health_is_503_degraded_when_the_database_is_gone | the under-100 ms timing on the Pi is not measured (AC-20.1) |
|
||||
| E-2 | `GET /setup/status` `{initialized}` | Done | src/router.rs:33, src/api/setup.rs; skeleton/routes.rs::setup_status_follows_the_admin_user; core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | |
|
||||
| E-3 | `POST /invites/accept` with token, invite_kind, user, device, household; errors | Done | src/router.rs:34, src/api/invites.rs:234-; core_api/invites.rs::e3_* (6 tests) | |
|
||||
| E-4 | `GET /i/{code}` landing HTML, sv/en, `from`, 404 | Done | src/router.rs:35-37, src/api/landing.rs:203-207; core_api/landing.rs::e4_* (4 tests); sync_api/serve.rs::empty_invite_code_is_the_html_404_page | |
|
||||
| E-5 | `GET /apk/latest`, `not_found` before publishing | Done | src/router.rs:38, src/api/apk.rs; core_api/apk.rs::e5_not_found_until_published_then_read_on_every_request | |
|
||||
| E-6 | `GET /apk/files/{file_name}` | Done | src/router.rs:39; core_api/apk.rs::e6_serves_existing_files_matching_the_pattern_only | |
|
||||
| E-7 | `GET /me` | Done | src/router.rs:43, src/api/me.rs; core_api/me.rs::e7_returns_user_device_household_and_public_url | |
|
||||
| E-8 | `PATCH /me` display name | Done | src/router.rs:43; core_api/me.rs::e8_changes_the_own_display_name_with_a_new_server_version, ::e8_invalid_request_and_validation_failed | |
|
||||
| E-9 | `PUT /me/picture` (5 MiB layer) | Done | src/router.rs:44-49, src/api/pictures.rs:39-43; core_api/pictures.rs::e9_* (4 tests) | |
|
||||
| E-10 | `DELETE /me/picture` with `-removed` marker | Done | src/api/pictures.rs:255-273; core_api/pictures.rs::e10_removes_idempotently_and_the_next_upload_continues_the_count | |
|
||||
| E-11 | `GET /users/{id}/picture?size=` | Done | src/router.rs:50, src/api/pictures.rs:281-289; core_api/pictures.rs::e11_not_found_and_invalid_request, ::e9_crops_resizes_and_reencodes_and_e11_serves_both_sizes | |
|
||||
| E-12 | `POST /invites` (admin), link with `?from=` | Done | src/router.rs:51-54, src/api/invites.rs:369-387; core_api/invites.rs::e12_partner_invite_matches_the_example, ::e12_device_invite_for_either_user_and_its_errors | |
|
||||
| E-13 | `GET /invites`, pending only | Done | src/api/invites.rs:429; core_api/invites.rs::e13_lists_pending_invites_only | |
|
||||
| E-14 | `DELETE /invites/{id}`, idempotent | Done | src/api/invites.rs:473; core_api/invites.rs::e14_cancel_is_idempotent_and_its_errors | |
|
||||
| E-15 | `GET /devices`, admin all / member own | Done | src/api/devices.rs:31; core_api/devices.rs::e15_admin_sees_all_devices_member_own_ordered_by_created_at | |
|
||||
| E-16 | `DELETE /devices/{id}`, `cannot_revoke_current_device` | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* (3 tests) | |
|
||||
| E-17 | `PUT /devices/current/push-token` | Done | src/api/devices.rs:100-110; core_api/devices.rs::e17_sets_and_clears_the_fcm_token, ::e17_invalid_request | |
|
||||
| E-18 | `POST /accounts` | Done | src/router.rs:65, src/api/accounts.rs; core_api/accounts.rs::e18_* (3 tests) | |
|
||||
| E-19 | `PATCH /accounts/{id}` | Done | core_api/accounts.rs::e19_renames_and_changes_the_ratio_like_the_example, ::e19_errors | |
|
||||
| E-20/E-21 | archive and unarchive an account, idempotent | Done | src/router.rs:70-77; core_api/accounts.rs::e20_e21_archive_and_unarchive_are_idempotent | |
|
||||
| E-22 | `DELETE /accounts/{id}`, `account_not_empty` | Done | core_api/accounts.rs::e22_soft_deletes_an_empty_account, ::e22_account_not_empty_for_expense_settlement_or_template | |
|
||||
| E-23 | `POST /categories` | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10, ::e23_invalid_request_and_validation_failed | |
|
||||
| E-24 | `PATCH /categories/{id}` (rename, reset, reorder) | Done | core_api/categories.rs::e24_renames_a_default_and_reorders_like_the_example_then_resets_the_name, ::e24_errors | |
|
||||
| E-25/E-26 | archive and unarchive a category | Done | core_api/categories.rs::e25_e26_archive_and_unarchive | |
|
||||
| E-27 | `DELETE /categories/{id}` | Done | core_api/categories.rs::e27_deletes_unused_custom_categories_only, ::e27_category_in_use_by_expense_or_template | |
|
||||
| E-28 | `POST /recurring-templates`, server mints the id, due period generated before reply | Done | src/api/recurring.rs; core_api/recurring.rs::e28_* (4 tests); core_api/generation.rs::catch_up_generates_every_due_period_on_create_with_audit_and_push | |
|
||||
| E-29 | `PATCH /recurring-templates/{id}` | Done | core_api/recurring.rs::e29_pauses_like_the_example_and_edits_fields, ::e29_errors | |
|
||||
| E-30 | `DELETE /recurring-templates/{id}` | Done | core_api/recurring.rs::e30_soft_deletes_and_then_not_found | |
|
||||
| E-31 | `GET /sync` pull | Done | src/router.rs:100, src/sync/pull.rs; sync_api/pull.rs (7 tests) | |
|
||||
| E-32 | `POST /sync` push | Done | src/sync/push.rs; sync_api/push_rules.rs (25 tests) | |
|
||||
| E-33 | `POST /settlements/proposal` | Done | src/router.rs:101, src/api/proposal.rs; core_api/proposal.rs::e33_* (4 tests) | |
|
||||
| OpenAPI | the generated document equals `contracts/openapi.yaml` | Done | tests/openapi_contract.rs::generated_document_equals_contract | |
|
||||
|
||||
#### CLI (§10.4)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| CLI-serve | migrations, scheduler, push sender, HTTP; SIGTERM drain 10 s; exit 0 | Done | src/cli/mod.rs:95-131, :25 | no automated test of the SIGTERM drain; used on the dev stack |
|
||||
| CLI-bootstrap | FR-1; exit 0, or 2 when already initialized | Done | src/cli/bootstrap.rs:27-74; core_api/cli.rs::bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses | |
|
||||
| CLI-pair-device | `--user`; exit 0, or 3 for an unknown user | Done | src/cli/pair_device.rs:19-64; same cli.rs test (lines 77-98) | |
|
||||
| CLI-rotate-epoch | new random epoch on stdout; exit 0 | Done | src/cli/rotate_epoch.rs; sync_api/serve.rs::rotate_epoch_sets_a_new_random_epoch_and_exits_0 | |
|
||||
| CLI-healthcheck | GET 127.0.0.1:<port>/health; exit 0 on 200, else 1 | Done | src/cli/healthcheck.rs:16-59; infra/pi/compose.yaml:65-66, infra/dev/compose.yaml:52-53 | no unit or integration test of the subcommand itself |
|
||||
| CLI-migrate | runs migrations only; exit 0 | Done | src/cli/mod.rs:88-93 | the CLI path is untested; `db::migrate` is tested in skeleton/migration.rs |
|
||||
|
||||
#### Acceptance criteria (server side)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-1.1 | bootstrap prints the code, link and expiry; exit 0 | Done | src/cli/bootstrap.rs:42-49; core_api/cli.rs::bootstrap_prints_… | |
|
||||
| AC-1.2 | users exist: `already initialized` on stderr, exit 2 | Done | src/cli/bootstrap.rs:65-67; cli.rs lines 67-75 | |
|
||||
| AC-1.3 | rerunning cancels the previous admin_setup invite | Done | src/cli/bootstrap.rs:35; cli.rs line 61 | |
|
||||
| AC-1.4 | accepting admin_setup creates the admin, the device and a token | Done | core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | |
|
||||
| AC-1.5 | `/setup/status` false, then true | Done | skeleton/routes.rs::setup_status_follows_the_admin_user | |
|
||||
| AC-1.6 | `pair-device --user` creates a device invite | Done | core_api/cli.rs lines 77-98 | |
|
||||
| AC-2.1 (server) | partner invite: `household_full`; a new one cancels the pending one | Done | src/api/invites.rs:385-387; core_api/invites.rs::e3_invite_invalid_for_a_cancelled_code_and_one_hour_before_expiry_it_works, ::e12_device_invite_for_either_user_and_its_errors | |
|
||||
| AC-2.3 | single use, 72 h, uniform `invite_invalid` | Done | src/api/invites.rs:33, :178; core_api/invites.rs::e3_invite_invalid_for_unknown_expired_used_cancelled_and_malformed_codes | |
|
||||
| AC-2.4 (server) | accept returns token, invite_kind, user, device, household; partner creates the member | Done | src/api/invites.rs:234-347; core_api/invites.rs::e3_partner_creates_the_member_and_device_pairs_an_existing_user | |
|
||||
| AC-2.5 | landing page sv/en, buttons, grouped code, `from` passthrough, no-referrer, no-store, 404 | Done | src/api/landing.rs:203-207; core_api/landing.rs (4 tests) | |
|
||||
| AC-2.12 | more than 10 failed accepts per IP per 60 min give `rate_limited` | Done | src/api/rate_limit.rs; core_api/rate_limit.rs::ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour | |
|
||||
| AC-3.2 (server) | revoke rules: admin any, member own, never the current device | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* | |
|
||||
| AC-3.3 (server) | revoked device gets 401 `device_revoked` | Done | src/auth.rs:127-136; core_api/devices.rs::e16_admin_revokes_another_device_which_then_gets_device_revoked | the phone side is out of this part |
|
||||
| AC-3.4 | a revoked device gets no pushes | Done | src/push/sender.rs:87,94 (`revoked_at IS NULL`); push_fcm/fcm.rs::recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both | |
|
||||
| AC-3.5 | never deleted; `revoked_at` set, `fcm_token` cleared | Done | src/api/devices.rs:72; devices.rs test lines 60-70 | |
|
||||
| AC-4.4 | image rules: types, 5 MiB, 8000 px, EXIF, crop, JPEG 85 at 512 and 128, strip, flatten, version | Done | src/api/pictures.rs:3-43,73; core_api/pictures.rs::e9_* | |
|
||||
| AC-5.2 | `household_incomplete` for accounts, templates, expense and settlement mutations | Done | src/api/rows.rs:105-110; accounts.rs::e18_household_incomplete_before_the_partner_joins; recurring.rs::e28_household_incomplete; push_rules.rs::step2_household_incomplete_fails_the_whole_request | |
|
||||
| AC-6.2 | names unique, case-insensitive after trimming, `name_taken` | Done | src/api/accounts.rs:20-31; accounts.rs::e18_invalid_request_validation_failed_and_name_taken | |
|
||||
| AC-6.5 (server) | delete only when empty (expense, allocation, template); tombstone | Done | core_api/accounts.rs::e22_account_not_empty_for_expense_settlement_or_template | |
|
||||
| AC-7.2 | fourteen seeded defaults | Done | migrations/0001, 0002; skeleton/migration.rs::a_fresh_database_holds_exactly_the_fourteen_defaults | |
|
||||
| AC-7.3 | `sort_order` = max + 10 | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10 | |
|
||||
| AC-7.6 (server) | `category_in_use`, `category_is_default`, soft delete | Done | core_api/categories.rs::e27_* | |
|
||||
| AC-9.2 | 06:00 Europe/Stockholm, days 29-31 clamp | Done | core_api/generation.rs::occurrence_is_at_0600_europe_stockholm_in_summer_and_winter, ::days_29_30_31_clamp_to_the_last_day_including_february_2027 | |
|
||||
| AC-9.3 / §9.5 | catch-up, idempotency, split resolution, pause and resume without backfill, clock jumps | Done | generation.rs::catch_up_…, ::an_existing_period_is_skipped_silently_by_the_unique_index, ::mode_default_resolves_the_account_ratio_at_generation_time, ::modes_ratio_full_and_exact_use_the_template_splits, ::paused_generates_nothing_and_resume_does_not_backfill, ::resume_before_start_period_keeps_the_start, ::end_period_stops_generation_and_next_occurrence_becomes_null; scheduler_daily/scheduler.rs::runs_at_start_then_at_06_00_and_survives_clock_jumps, ::next_06_00_is_local_time_across_both_dst_changes | |
|
||||
| AC-9.4 (server) | template edits affect only later expenses | Done | generation.rs::archived_account_continues_and_edits_affect_only_later_expenses | |
|
||||
| AC-9.5 | a deleted generated expense is not recreated | Done | generation.rs::a_deleted_generated_expense_is_not_recreated | |
|
||||
| AC-9.6 | audit `source` recurring; push to both users | Done | sync_api/audit.rs::generation_writes_recurring_audit_with_server_now_and_no_device; push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | |
|
||||
| AC-9.7 (server) | `next_occurrence_date` computed by the server | Done | kvitt-domain/src/recurring.rs; generation.rs::end_period_stops_generation_and_next_occurrence_becomes_null | |
|
||||
| AC-11.8 | E-33 equals `propose_allocation`, shared vectors | Done | kvitt-domain/tests/vectors.rs::every_vector_file_passes; core_api/proposal.rs::e33_matches_the_example, ::e33_honours_exclude_settlement_id_and_settlements | |
|
||||
| AC-12.1 | audit for every accepted create, update, delete (app, scheduler, importer); append-only | Done | src/audit/mod.rs; sync_api/audit.rs::entries_are_append_only_create_update_delete; kvitt-import tests/import.rs::english_export_imports_expenses_settlements_and_audit | |
|
||||
| AC-12.2 | entry fields: actor device null for scheduler and importer, client_at, server_at, diff §14.4.7 | Done | src/audit/diff.rs (with unit tests); sync_api/audit.rs; push_rules.rs::step11_newer_update_writes_the_diff_and_keeps_server_owned_fields | |
|
||||
| AC-13.1 | push to the other user's live devices with a token | Done | src/push/sender.rs:87-94; push_fcm/fcm.rs::recipients_…; sync_api/e2e.rs::create_on_device_a_pull_on_device_b_row_audit_and_one_push | |
|
||||
| AC-13.2 | recurring generation goes to both users | Done | push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | |
|
||||
| AC-13.3 | the importer sends no pushes | Done | server/crates/kvitt-import/src/db.rs:4 (no push queue in the crate) | true by construction; no explicit test |
|
||||
| AC-13.4 | more than 3 notifiable changes give one `sync_batch` | Done | src/sync/push.rs:36,713-715; push_rules.rs::more_than_3_notifiable_changes_send_one_sync_batch_3_send_each | |
|
||||
| AC-13.7 (server) | sends to `message.fid` | Done | src/push/fcm.rs:116-121; push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example | |
|
||||
| AC-13.8 | no personal content in the payload | Done | push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example; core_api/logging.rs::logs_hold_no_personal_content | |
|
||||
| §14.6 queue | bounded at 100, the oldest dropped | Done | src/push/queue.rs:12,62; push_fcm/fcm.rs::full_queue_drops_the_oldest | |
|
||||
| §14.6 auth | RS256 JWT, token cached until expiry minus 5 min, 401 drops the token | Done | src/push/fcm.rs:268,302; fcm.rs::oauth_jwt_and_send_request_follow_the_http_v1_flow, ::access_token_is_cached_until_5_minutes_before_expiry | |
|
||||
| §14.6 retries | 1 s, 5 s, 25 s on 429, 5xx, network | Done | src/push/sender.rs:26-28; fcm.rs::production_retry_delays_are_1_5_25_seconds, ::retries_429_and_5xx_…, ::network_errors_are_retried | |
|
||||
| §14.6 unregistered | `UNREGISTERED` or 404 clears the token if unchanged | Done | src/push/fcm.rs:93,135-142,296; fcm.rs::unregistered_or_404_clears_the_devices_token | |
|
||||
| §14.6 disabled | missing credentials: Noop, one warning | Done | fcm.rs::missing_or_unusable_credentials_disable_push | |
|
||||
| §14.4.2 pull | cursor_ahead, params, order, paging, tombstones, epoch | Done | src/sync/pull.rs; sync_api/pull.rs (7 tests) | |
|
||||
| §14.4.4 pre-checks | 0 or more than 100 is `batch_too_large`, 1 MiB, invalid envelope | Done | push_rules.rs::batch_of_101_is_batch_too_large_and_0_too_and_100_is_fine, ::body_over_1_mib_is_payload_too_large, ::malformed_body_or_envelope_is_invalid_request | |
|
||||
| §14.4.4 steps 1-4 | replay, household, shape, clock_skew | Done | push_rules.rs::step1_…, ::duplicate_mutation_ids_…, ::step2_…, ::step3_…, ::step4_… | |
|
||||
| §14.4.4 steps 5-8 | tombstone, delete wins, never-synced tombstone, validation and references | Done | push_rules.rs::step6_…, ::step7_… (2), ::step8_… (5) | |
|
||||
| §14.4.4 steps 9-12 | insert, stale, diff update, empty diff, record mutation | Done | push_rules.rs::step9_…, ::step10_…, ::step11_… (2), ::step12_…; sync_api/conflicts.rs (5 tests) | |
|
||||
| AC-15.6 (server) | 14 days offline with 200 changes syncs | Done | sync_api/offline.rs::fourteen_days_offline_200_changes_all_sync | the phone side is out of this part |
|
||||
| §14.1 pruning | `sync_mutations` older than 90 days pruned at 04:00 | Done | src/tasks/scheduler.rs:106-109; scheduler.rs::pruning_deletes_rows_older_than_90_days_and_keeps_the_rest, ::pruning_runs_at_04_00_local | |
|
||||
| §8.6 | 60/min per IP, 600/min per device, XFF from the trusted proxy only, body limits 1 MiB and 5 MiB | Done | src/router.rs:22-24,108-109; core_api/rate_limit.rs (5 tests); sync_api/serve.rs::two_client_addresses_get_separate_rate_limit_buckets_end_to_end | |
|
||||
| AC-17.1 | `kvitt-import` CLI, one file per group into a named account | Done | server/crates/kvitt-import/src/cli.rs:87-114; tests/import.rs::cli_end_to_end_exit_codes, ::cli_help_and_usage_errors | |
|
||||
| AC-17.2 | a rerun creates nothing (`import_key`) | Done | kvitt-import/src/db.rs:111,345; import.rs::rerun_creates_nothing_and_existing_account_is_reused | |
|
||||
| AC-17.3 | fixed category table; unknown names map to `other` and are listed | Done | kvitt-import/data/splitwise_categories.csv; import.rs::category_table_matches_the_spec, english_export… lines 198-262 | |
|
||||
| AC-17.4 | payments become settlements with one allocation | Done | import.rs lines 296-300 | |
|
||||
| AC-17.5 | `--dry-run` rolls back, same counts and exit status | Done | import.rs::dry_run_writes_nothing_and_reports_the_same_counts | |
|
||||
| AC-17.6 | audit `source` import, no pushes | Done | kvitt-import/src/db.rs:300; import.rs lines 335- | |
|
||||
| AC-18.1 | nightly 03:30 snapshot and pictures archive, gzip, rclone to Drive with `--immutable` | Partial | infra/backup/Dockerfile:22-27, infra/backup/backup.sh:27-69, infra/pi/compose.yaml (backup service) | implemented; never run on the Pi (M2c-pi is owner-run, D-100); no automated test |
|
||||
| AC-18.2 | restore drill documented in `docs/validation/restore-drill.md` | Missing | infra/RESTORE.md (88 lines, the procedure) | `docs/validation/` does not exist; the drill has not been run |
|
||||
| AC-20.1 | `/health` unauthenticated, in under 100 ms on the Pi | Partial | skeleton/routes.rs::health_* | function Done; the 100 ms target on the Pi is not measured (not verifiable without the Pi) |
|
||||
| AC-20.2 | the compose healthcheck of `server` uses it | Done | infra/pi/compose.yaml:65-66; infra/dev/compose.yaml:52-53 | |
|
||||
|
||||
|
||||
### Part B: Android side, FR-2 to FR-12, §9.2/§9.8/§9.9/§9.11, screens S-1 to S-10 (main at ed1b130)
|
||||
|
||||
Convention: Done = code present in the real `:data`/feature/`:app` path and a test (unit, screenshot or the M8 phone check) demonstrates it; Partial = code present but the §4 evidence rule is not met, or a sub-clause missing; Not verifiable without device = only an on-device behaviour (system UI) proves it. Test names are JUnit method names in the cited file. Paths are relative to `android/`.
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-2.1 (phone) | Admin creates a partner invite from Settings; new one cancels pending | Done | feature/settings/.../invites/InvitesViewModel.kt:88 `invitePartner`; hub/SettingsHubViewModel.kt:47; StateHolderTest.invitesCreateAndRefuse | `household_full`, cancel-previous are server rules (fork A) |
|
||||
| AC-2.2 (phone) | Admin creates a device invite for either user | Done | InvitesViewModel.kt:91 `inviteDevice(user)`; InvitesScreen.kt:198 | |
|
||||
| AC-2.4 (phone) | Accept takes code, device name, display name; returns kind, user, household | Done | data/.../repo/SessionRepositoryImpl.kt; SessionRepositoryTest.pair_storesCredentials_writesTheHousehold_andStartsAFullSync, pair_reportsTheInviteKind_… | |
|
||||
| AC-2.6 | `kvitt://invite` and `https://…:8443/i/<CODE>` open S-1 prefilled; `from` names the admin; ignored while paired | Done | app/src/main/AndroidManifest.xml:31-47 (`autoVerify="false"`, scheme kvitt host invite, https host/port/pathPrefix /i/); app/.../link/AppLinks.kt; nav/KvittApp.kt:210-221 (prefill), :130-137 (S-1 only when unpaired); AppLinksTest.appLinkFillsServerAndCode, landingLinkFillsTheServerFromTheLink, linksWithTheAdminNameCarryIt_andOldLinksStillParse; InviteLinkTest | "Ignored while paired" holds because S-1 is not in the paired graph; the parsed invite stays in `AppViewModel.invite` (AppViewModel.kt:47-49) and would prefill S-1 if the phone later becomes unpaired. No test of the paired case. Chooser behaviour is device-only |
|
||||
| AC-2.7 | Manual entry; URL prefilled in release; code normalised per §8.3 | Done | feature/onboarding/.../ServerAddress.kt:7; PairingViewModelTest.theCodeIsNormalisedWhileTyping, aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName; ComponentLogicTest.inviteCodesAreNormalisedPerSpec83 | |
|
||||
| AC-2.8 | Release https only; debug also http for localhost/10.0.2.2/private LAN, in `normaliseServerUrl` | Done | SessionRepositoryTest.serverUrls_releaseHttpsOnly_debugAlsoLocalHttp; ServerAddressTest.debugAcceptsHttpOnlyForLocalAndPrivateAddresses; PairingViewModelTest.releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan | |
|
||||
| AC-2.9 | Full sync after pairing, then ask POST_NOTIFICATIONS | Not verifiable without device | app/.../AppViewModel.kt:60-66 (waits for `lastSuccessAt != null && !running`); nav/KvittApp.kt:104-116 | No unit test of AppViewModel; the system prompt is device-only |
|
||||
| AC-2.10 | Partner pairing goes to S-2; every other pairing to Home | Done | nav/KvittApp.kt:213-217; PairingViewModelTest.aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin, theAdminGoesHomeAndTheAgainVariantSendsNoName | |
|
||||
| AC-2.11 | Admin sees pending invites (kind, created, expiry) and cancels each | Done | InvitesScreen.kt:223-263; InvitesViewModel.kt:118 `cancel`; DevicesInvitesAboutScreenshotTest | |
|
||||
| AC-3.1 | Devices list: name, owner, paired, last seen, app version name else code, "This device" | Done | feature/settings/.../devices/DevicesScreen.kt; DevicesViewModel.kt:77; StateHolderTest.devicesLoadFailAndRevoke; DevicesInvitesAboutScreenshotTest | Revoked devices are filtered out of the list (DevicesViewModel.kt:77); the spec does not ask to show them |
|
||||
| AC-3.2 (phone) | Admin revokes any but current; member own but current | Done | DevicesViewModel.kt:39-40 `canRevoke`; PureRulesTest.revokeRules | Server enforces too (fork A) |
|
||||
| AC-3.3 | On 401 device_revoked: delete token, DB, cached pictures; show S-18 "Pair again" | Done | data/.../di/DataModule.kt:166-170 (clearAllTables, Coil memory and disk cache); auth/SessionManager.kt:35; SyncEngineTest.deviceRevoked_wipesTokenAndLocalData; CredentialStoreTest.revokedDeletesTokenAndKey_unrecognisedKeepsTheUser; KvittApp.kt:133, :226-231 | |
|
||||
| AC-4.1 | Display name and optional picture | Done | model User(displayName, pictureVersion) | |
|
||||
| AC-4.2 | Edit own display name in Settings, online only | Done | feature/settings/.../profile/ProfileViewModel.kt; StateHolderTest.profileNameRulesAndSave, profileOfflineDisablesEverything | |
|
||||
| AC-4.3 | Picture from the Android photo picker or removed; online only | Not verifiable without device | profile/ProfileScreen.kt:59-74 and onboarding ProfileStepScreen.kt:96-117 (`PickVisualMedia`); StateHolderTest.profileRefusedNameIsInlineAndPictureErrorsAreSnackbars; OtherViewModelsTest.offlineThePictureCannotChange | Picker itself is system UI |
|
||||
| AC-4.5 | Initials on avatar-1/avatar-2, slot a stable function of the user id | Done | core/design/.../component/People.kt:55; ComponentLogicTest.initialsAreTheFirstLettersOfTheFirstTwoWords, avatarSlotIsTheLowestIdFirstOnBothPhones | |
|
||||
| AC-4.6 | Pictures cached on disk (Coil) keyed by user id and picture_version | Not verifiable without device | data/.../repo/Pictures.kt:26-38 (disk cache, URL carries user id and `v=`) | Offline display not tested |
|
||||
| AC-5.1 | Without partner, Home is "Waiting for your partner" with "Invite partner"; no creation offered | Done | nav/KvittApp.kt:236-240; feature/onboarding WaitingScreen.kt; OtherViewModelsTest.waitingShowsThePendingInviteExpiryOnlyOnline; OnboardingScreenshotTest | Gate in `:app` not unit-tested |
|
||||
| AC-6.1 | Create account online, starts 50/50, percent with two decimals, other = 100 − first | Done | feature/account AccountEditorViewModel.kt; AccountViewModelTest.newAccountStartsAtFiftyFiftyAndNeedsAName, percentsAcceptTwoDecimalsAndTheOtherFollows, createSavesBasisPointsAndReportsTheNewId | |
|
||||
| AC-6.2 (phone) | Duplicate name shows `name_taken` | Done | AccountViewModelTest.nameTakenIsShownUnderTheName | Rule is server-side |
|
||||
| AC-6.3 | Rename or change ratio online; ratio change affects only later expenses | Done | AccountViewModelTest.editStartsFromTheAccount; default ratio copied into the expense at save (ExpenseRepositoryImpl.kt save; RepositoriesTest.preview_usesTheAccountDefault) | |
|
||||
| AC-6.4 | Archive/unarchive online; hidden from pickers; collapsed on Home; still in balance, analytics, settle | Done | data ExpenseRepositoryImpl.kt:57 (pickers active only); HouseholdRepositories.kt:100-101; feature/settle SettleViewModel.kt:81-82 (archived addable); AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar, archivedAccountHidesTheFab; HomeViewModelTest.archivedSectionToggles | Undo snackbar wired: KvittApp.kt:289-293 |
|
||||
| AC-6.5 (phone) | Delete only when no live expense, allocation or template references it | Done | data HouseholdRepositories.kt:151-154 `accountDeletable`; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteOfANonEmptyAccountIsRefused, deleteAsksThenDeletes | |
|
||||
| AC-6.6 | Accounts by created_at ascending | Done | data Snapshot.kt:90 | |
|
||||
| AC-6.7 | Account create/edit disabled offline with "Needs connection" | Done | AccountEditorScreen.kt:171; AccountViewModelTest.offlineCannotSave, offlineActionSaysNeedsConnection | |
|
||||
| AC-7.1 | One global editable list: name, icon token, colour token | Done | model Category; ContractTest.categoryIconsAreTheThirtyThreeTokensOfSpec9_7InOrder | |
|
||||
| AC-7.4 | Rename, re-icon, recolour, reorder; "Reset name" for defaults | Done | StateHolderTest.categoryResetNameSavesNull, categoryReorderFailureRestoresTheOrder; CategoriesScreenshotTest | |
|
||||
| AC-7.5 | Archived categories not offered for new expenses/templates, kept on existing ones and analytics | Done | data ExpenseRepositoryImpl.kt:58; ComponentLogicTest.quickRowSkipsArchived; StateHolderTest.archiveReturnsWithASnackbarForTheList | |
|
||||
| AC-7.6 (phone) | Delete only if unused and not default; reason shown | Done | EditorCategoriesTest.deletable_falseForDefaults_unknownAndDeleted, deletable_followsExpensesAndTemplates_archivedStillCounts; StateHolderTest.categoryDeleteAffordanceFollowsDeletable | |
|
||||
| AC-7.7 | No uniqueness rule for category names | Done | no duplicate check in feature/settings categories (CategoryEditorViewModel.kt) | Absence of a rule |
|
||||
| AC-8.1 | Expense fields as listed | Done | model ExpenseDraft / ExpenseDetailState; data db/Entities.kt | |
|
||||
| AC-8.2 | Create/edit/delete any expense online or offline; UUIDv7 client id | Done | RepositoriesTest.expenseSave_writesAPendingRowWithAMutationIdAndTimestamps; ExpenseEditorViewModelTest.offlineSave_isAllowed_andReportsOffline; data util/Ids.kt | |
|
||||
| AC-8.3 | Payer either user, defaults to current user | Done | ExpenseEditorViewModelTest.newExpense_appliesTheDefaults | |
|
||||
| AC-8.4 | Any date within limits, default today in phone zone | Done | ExpenseEditorViewModelTest.dateTooFarAhead_comesBackAsValidationFailed, newExpense_appliesTheDefaults | |
|
||||
| AC-8.5 | Split modes default/ratio/exact/full, shares per §9.1 | Done | domain Split.kt; VectorTest (compute_split.json); ExpenseEditorViewModelTest.ratio_…, full_defaultsToThePersonWhoDidNotPay | |
|
||||
| AC-8.6 | Editor defaults: last-used account else oldest active; category suggestion then last-used else `other`; none if `other` archived | Done | data ExpenseRepositoryImpl.kt:54-74; RepositoriesTest.editorDefaults_lastUsedAccountAndCategory_elseOldestActiveAndOther; ExpenseEditorViewModelTest.noDefaultCategory_showsNoCategory_andBlocksSaving | |
|
||||
| AC-8.7 | Exact mode: other share read-only; typed > amount blocks saving | Done | ExpenseEditorViewModelTest.exactShareLargerThanTheAmount_blocksSaving | |
|
||||
| AC-8.8 | Account change in default mode re-copies ratio; other modes kept | Done | ExpenseEditorViewModelTest.accountChange_inDefaultMode_recopiesTheRatio | |
|
||||
| AC-8.9 | Delete asks for confirmation, soft delete, gone from lists/balances/analytics | Done | ExpenseDetailViewModelTest.delete_asksThenDeletes; RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones, analytics_includePending_excludeDeleted; SyncEngineTest.delete_pushesTheFullEntityWithDeletedAt_andKeepsTheTombstone | |
|
||||
| AC-8.10 | Rows: icon, colour, description, date, amount, effect line; date desc then created_at desc | Done | core/design component/Ledger.kt, Money.kt:137-148; data HouseholdRepositories.kt:157-166 (ledgerOrder); AccountScreenshotTest | |
|
||||
| AC-8.11 | Generated expense is ordinary; detail shows "Recurring" with template name | Done | ExpenseDetailScreen.kt:217, :238-239; ExpenseDetailViewModelTest.recurring_namesTheTemplate | |
|
||||
| §9.2 | Effect line Lent / Borrowed / Not involved / Paid for yourself | Partial | data Snapshot.kt:124-132 `effectFor`; core/design Money.kt:137-148 | Logic is correct by reading, but no `:data` unit test covers the four cases (screenshots use the fakes) |
|
||||
| CS-1 | Suggestion only for a new expense, never on edit | Done | ExpenseEditorViewModel.kt:217-218, :493; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | |
|
||||
| CS-2 | Picking by hand stops suggestions for the session | Done | ExpenseEditorViewModel.kt:339; ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault | |
|
||||
| CS-3 | Recompute on description and account change, local only | Done | ExpenseEditorViewModel.kt:326, :347, :491-502 | 150 ms debounce (not in spec, harmless) |
|
||||
| CS-4 | Normalisation (NFC, lowercase, delete ' ’ :, non-letter/digit/& to space) | Done | domain CategorySuggestion.kt:49; CategoryKeywordsTest.platformUnicodeText_nfc_letters_nd_digits_supplementary; VectorTest (suggest_category.json) | |
|
||||
| CS-5 | Order: exact history, first-token history, keywords, default; ties most recent then id | Done | domain CategorySuggestion.kt:74-122; CategorySuggestionTest.history_countsEveryAccountAndBothUsers_mostOftenWins_deletedLeftOut, history_tie_goesToTheMostRecentlyUsed, fallback_isTheCategoryLastUsedInTheAccount_elseOther | |
|
||||
| CS-6 | Keyword table token/prefix/phrase, longest wins, archived skipped | Done | CategorySuggestion.kt:134-143; CategorySuggestionTest.keywords_applyWithoutHistory_andNeedTheKeysCategory, archivedCategories_areSkipped_inHistoryAndKeywords; data CategoryKeywordsTest.bundledTableIsByteIdenticalToTheContractFile | |
|
||||
| CS-7 | No marker; clearing returns to step-4 default | Done | ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault; ExpenseScreenshotTest | |
|
||||
| AC-10.1 | Home global balance wording from the viewer's side | Done | HomeViewModelTest.householdShowsBalanceAccountsAndFab, settledHasNoSettleButton; HomeScreenshotTest | |
|
||||
| AC-10.2 | Active accounts with balance; archived collapsed | Done | HouseholdRepositories.kt:100-101; HomeViewModelTest.archivedSectionToggles | |
|
||||
| AC-10.3 | Account screen: balance and expenses and settlements interleaved by date | Done | HouseholdRepositories.kt:157-166; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount; AccountViewModelTest.loadedAccountShowsBalanceMonthsAndFab | |
|
||||
| AC-10.4 | Balances on the phone, include pending, exclude deleted, update after local write | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones; domain Balances.kt; VectorTest (compute_balances.json) | |
|
||||
| AC-10.5 | Global = sum of per-account balances, archived included | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones (assert "AC-10.5") | |
|
||||
| AC-11.1 | Settlement: amount, date, optional note, ≥1 allocations | Done | model SettleRequest; SettleViewModelTest.invalidNoteIsReportedAndClearedOnEdit | |
|
||||
| AC-11.2 | Allocations sum to amount, each positive, account at most once | Done | domain Allocation.kt; VectorTest (validate_settlement.json); SettleViewModelTest.removeAndAddAccountsUpdateTheRows | |
|
||||
| AC-11.3 | Global settle-up defaults, proposal rows, "Add account" (archived too), confirm only when sums match, live remaining | Done | data SettlementRepositoryImpl.kt:57-61; SettleViewModel.kt:81-84; RepositoriesTest.settleStart_theUserWhoOwesPays_amountIsTheBalance; SettleViewModelTest.globalDefaultsAreTheProposal, zeroBalanceGivesEmptyAmountAndNoRows | |
|
||||
| AC-11.4 | Per-account settle-up: one allocation, defaults to account balance and debtor | Done | SettlementRepositoryImpl.kt:59; SettleViewModelTest.perAccountHasOneFixedRowThatFollowsTheAmount | |
|
||||
| AC-11.5 | Over-settle allowed, warned per row, confirm dialog repeats | Done | SettleViewModel.kt:102, :194; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | |
|
||||
| AC-11.6 | Create/edit/delete offline; editing excludes itself; saved rows count as edited; "Suggest again" | Done | SettleViewModel.kt:157, :184; SettleViewModelTest.editingStartsFromTheSavedSettlement, offlineWorksUnchanged; RepositoriesTest.settleState_listsTheAccountsTheSettlementLeavesUntouched | |
|
||||
| AC-11.7 | Settlement row "<Payer> paid <Recipient> X", account count when > 1; allocation in S-5 | Done | core/design Ledger.kt:115-128; data Snapshot.kt:141; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount | |
|
||||
| AC-12.3 (phone) | Audit entries reach the phone through pull | Done | SyncEngineTest.pull_auditEntriesAreInsertOrIgnore | |
|
||||
| AC-12.4 | Detail timeline, newest last, field names in UI language, ids resolved to names | Done | data repo/Timeline.kt:31-69; core/design component/Timeline.kt:50-64, :76-93; RepositoriesTest.expenseDetail_timelineResolvesNames_andEndsWithThePendingLine | `splits`/`allocations` rendered as "name amount; …" (accepted in §14.2.4 v0.5) |
|
||||
| AC-12.5 | Pending local change ends the timeline with "Your change is waiting to sync" | Done | Timeline.kt (data):38-44; ExpenseDetailViewModelTest.loaded_withThePendingLineLast; SettlementDetailViewModelTest.pendingChangeEndsTheTimeline | |
|
||||
| §9.8 money | `1 234,50 kr` / `SEK 1,234.50`, no sign | Done | core/design format/MoneyFormatter.kt; MoneyFormatterTest.swedishUsesNoBreakSpacesAndCommaDecimal, otherLocalesPutSekFirstWithCommaThousands, neverShowsASign | |
|
||||
| §9.8 dates | List, detail, timeline, axis, day ranges | Done | DateFormatterTest.listDatesWithoutYearInTheCurrentYear, listDatesWithYearInOtherYears, detailDates, timesAre24HourInTheLocalZone, monthNamesAndLongDayRanges | |
|
||||
| §9.8 relative | "just now", "N min ago", "N h ago", else date and time | Done | DateFormatterTest.relativeLastSynced | |
|
||||
| §9.8 file sizes | SI MB one decimal half up | Done | MoneyFormatterTest.megabytesHaveOneDecimal | |
|
||||
| §9.9 | Online-only gate: validated network and 30 s after a network error; REST failure changes nothing locally | Done | data connectivity/Connectivity.kt; RepositoriesTest.onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing, onlineOnly_withinThirtySecondsOfANetworkError_isOffline, rest_networkFailure_changesNothingLocally; KvittApiTest.networkFailureIsNetworkError_andOpensTheThirtySecondWindow | Per-screen gating of S-13 to S-22 is fork C's |
|
||||
| S-1 Pair | Pair with URL, code, name; progress; errors invite_invalid, network, rate_limited | Done | PairingViewModelTest.errorsMapToTheirFields, whilePairingTheButtonIsBusyAndEditingClearsTheFieldError, offlinePairingGivesTheNetworkErrorUnderTheExpandedServer; OnboardingScreenshotTest | |
|
||||
| S-1 Paste code | Paste fills code (and server from a pasted link) | Done | PairingViewModelTest.pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters | |
|
||||
| S-2 Continue | Name prefilled, blank shows error, "Add picture", "You are joining <owner>" | Done | KvittApp.kt:223-225; OtherViewModelsTest.theProfileStepPrefillsTheNameAndNamesTheOwner, continueSavesAChangedNameAndStaysOnFailure, anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason | |
|
||||
| S-3 Invite partner | Goes to S-21 | Done | KvittApp.kt:239 | |
|
||||
| S-3 Settings | Bottom bar to Settings | Done | WaitingScreen.kt:100 (`KvittNavigationBar`), KvittApp.kt:239 `onNavigate = shell::top` | |
|
||||
| S-3 must show | Expiry of pending invite; error snackbar with retry | Done | WaitingScreen.kt:91-93, :150-168; OtherViewModelsTest.aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain | |
|
||||
| S-4 Add expense (FAB) | FAB opens S-7 | Done | KvittApp.kt:242; HomeViewModelTest.emptyAndOnlyArchivedHideTheFab | |
|
||||
| S-4 Settle up | Button when non-zero opens S-9 | Done | KvittApp.kt:243; HomeViewModel.kt:53 | |
|
||||
| S-4 open account | Row opens S-5 | Done | KvittApp.kt:244 | |
|
||||
| S-4 New account | Opens S-6, then S-5 of the new account | Done | KvittApp.kt:245, :301-304 | |
|
||||
| S-4 must show | Sync line (tap S-16), update banner with Later, expenses this month, empty state | Done | KvittApp.kt:246; HomeViewModel.kt:96-124; HouseholdRepositories.kt:93; HomeViewModelTest.offlineAndPendingAreReported, laterHidesTheBanner, firstUpdateOpensTheDialogAndContinueInstalls; HomeScreenshotTest | |
|
||||
| S-5 Add expense | FAB (not on archived) opens S-7 in this account | Done | KvittApp.kt:286; AccountViewModelTest.archivedAccountHidesTheFab; ExpenseEditorViewModelTest.openedFromAnAccount_startsInThatAccount | |
|
||||
| S-5 Settle this account | Opens S-9 per-account | Done | KvittApp.kt:287 | |
|
||||
| S-5 Edit account | Opens S-6 | Done | KvittApp.kt:288 | |
|
||||
| S-5 Archive / Unarchive | With undo snackbar on Home after archive | Done | KvittApp.kt:289-293; AppViewModel.kt:73-75; AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar | |
|
||||
| S-5 Delete (only if allowed) | Shown only when deletable, confirmation | Done | AccountDetailScreen.kt:160; KvittApp.kt:294; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteAsksThenDeletes | |
|
||||
| S-6 Save | Name, split control, preview "Of 100,00 kr …", needs connection | Done | AccountEditorScreen.kt:171, :273-282; KvittApp.kt:301-304; AccountViewModelTest.createSavesBasisPointsAndReportsTheNewId | |
|
||||
| S-6 Cancel | Discard dialog title only | Done | AccountEditorScreen.kt:94, :132-137; AccountViewModelTest.closingWithChangesAsksFirst | |
|
||||
| S-7 Save | Saves and shows "Utgiften sparades" on the screen returned to | Done | KvittApp.kt:311-314; ExpenseEditorViewModelTest.tenSecondPath_amountDescriptionSuggestionSave, editSave_reportsAnEdit | |
|
||||
| S-7 Cancel | Discard dialog only with changes | Done | KvittApp.kt:310; ExpenseEditorViewModelTest.close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst | |
|
||||
| S-7 Delete (edit mode) | Delete, closes detail too, snackbar | Done | KvittApp.kt:315-319; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | |
|
||||
| S-7 must show | Four fixed rows, quick row, notes collapsed, live preview | Done | EditorOptionRowsLayoutTest.rowsStayPutWhenValuesChange; ExpenseEditorViewModelTest.quickRow_…; ExpenseEditorScreen.kt:265-266; ExpenseScreenshotTest | |
|
||||
| S-8 Edit | Opens S-7 in edit mode | Done | ExpenseDetailScreen.kt:130-131; KvittApp.kt:325 | |
|
||||
| S-8 Delete | Confirmation, back, snackbar | Done | ExpenseDetailScreen.kt:132; KvittApp.kt:326-329; ExpenseDetailViewModelTest.delete_asksThenDeletes | |
|
||||
| S-8 must show | Deleted state; missing state with "Synka nu" online only | Done | ExpenseDetailScreen.kt:143-170, :244-257; ExpenseDetailViewModelTest.missing_isNullAfterLoading, deleted_staysVisibleAsDeleted | |
|
||||
| S-9 Confirm | Saves; with warnings via dialog; snackbar on return | Done | KvittApp.kt:336-339; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | |
|
||||
| S-9 Cancel | Discard dialog only with changes | Done | KvittApp.kt:335; SettleViewModelTest.closeAsksOnlyWithChanges | |
|
||||
| S-9 must show | Direction swap, remaining, add account, date, note, untouched info line | Done | SettleViewModel.kt:163, :175; SettleViewModelTest.swapReproposesForTheOtherPayer; SettleScreenshotTest | |
|
||||
| S-10 Edit | Opens S-9 editing | Done | KvittApp.kt:345 | |
|
||||
| S-10 Delete | Confirmation, back, snackbar | Done | SettlementDetailScreen.kt:110, :139-147; KvittApp.kt:346-349; SettlementDetailViewModelTest.deleteAsksThenDeletes | |
|
||||
| S-10 must show | Missing state with "Synka nu" online only; deleted state | Done | SettlementDetailViewModel.kt:46-49, :97; SettlementDetailViewModelTest.missingOfflineCarriesTheConnectivityForSyncNow, deletedHasTheDeletionLineAndNoActions | |
|
||||
|
||||
|
||||
### Part C: Android recurring, push, analytics, sync, update, localisation; S-11 to S-22; UI contract; TODO scan
|
||||
|
||||
Audited at `ed1b130`, read-only. Paths are relative to `android/` unless they start with `server/`. "fork A" means the server-side rows are in part A.
|
||||
|
||||
#### (1) Table
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-9.1 | Create, edit, pause, resume and delete a template (online), with every field | Done | `data/.../repo/SettingsRepositories.kt:135-185` (E-28/E-29/E-30 via `OnlineGate`); `feature/settings/.../recurring/RecurringEditorViewModel.kt:187,221,250`; StateHolderTest `newTemplateDefaultsAndSave`, `editTemplateLoadsSplitAndMarksServerErrors` | Pause and resume are the editor's pause switch, saved with E-29 |
|
||||
| AC-9.4 | Editing a template changes only expenses generated later | Done | server test `generation.rs::archived_account_continues_and_edits_affect_only_later_expenses` | Server-side rule; the phone only sends E-29 |
|
||||
| AC-9.7 | List shows description, amount, account, day of month, paused state, next occurrence (server `next_occurrence_date`) | Done | `feature/settings/.../recurring/RecurringListScreen.kt:119-153`; `SettingsRepositories.kt:245` (`nextOccurrence = row.nextOccurrenceDate`); RecurringScreenshotTest | |
|
||||
| AC-9.8 | Default `start_period` (current month if the day is today or later, else next); new day = today; backfill up to 12 months | Done | `feature/settings/.../recurring/Periods.kt:24-33`; `RecurringForm.kt:136,200`; PureRulesTest `periods`, `templateValidation` | |
|
||||
| AC-13.5 | Notification only after its sync; text from local data in the phone's language; generic text when the sync fails within 20 s | Done | `app/.../push/PushWork.kt:25-30`; `data/.../sync/SyncTriggers.kt:70-75` (20 000 ms); `app/.../push/Notifications.kt:50-57`; NotificationTextsTest (`failedSyncGivesTheGenericTextWithoutContent`, `missingEntityGivesTheGenericText`); SyncTriggersTest `pushWaitGivesUpAfterItsTimeout` | Real delivery was seen in the M8 phone test (HANDOFF) |
|
||||
| AC-13.6 | A tap opens S-8 or S-10; Home for deletions, `sync_batch` and the generic text | Done | `app/.../push/NotificationTexts.kt` (targets); `nav/KvittApp.kt:136-144`; NotificationTextsTest `expenseDeletedOpensHome`, `settlementDeletedOpensHome`, `syncBatchCountsAndOpensHome`; AppLinksTest | |
|
||||
| AC-13.7 | Register by FID when paired and at each start; send every FID with E-17; unregister on revoke | Done | `app/.../push/PushHandler.kt:37,52-62,68-76`; `adapter/fcm/.../KvittMessagingService.kt:22-23` | The M8 phone test confirmed `fcm_token` was registered. The FID is sent only while PAIRED, and is not retried after a network failure until Firebase calls `onRegistered` again |
|
||||
| §14.6-1 | Phone step 1: `syncForPush`, waiting up to 20 s, in expedited work | Done | `PushWork.kt:40-54` (`setExpedited`); `SyncTriggers.kt:70-75` | |
|
||||
| §14.6-2 | Channel `activity` ("Activity"/"Aktivitet", importance default); id = hash of `entity_id`; `sync_batch` uses id 1 | Done | `Notifications.kt:27-35`; `KvittApplication.kt:34`; `NotificationTexts.kt` `NotificationIds` (SYNC_BATCH = 1, `entityId.hashCode()`); NotificationTextsTest `updateReplacesCreate`, `syncBatchIsFixed` | |
|
||||
| §14.6-3 | Tap targets as in AC-13.6 | Done | see AC-13.6 | |
|
||||
| §14.6-4 | An unknown `type` or a `v` other than 1 syncs only, with no notification | Done | `adapter/fcm/.../PushMapping.kt:19-21`; `PushHandler.kt:32-35`; PushMappingTest `otherPayloadVersionsAreSyncOnly`, `unknownTypeIsKeptForTheAppToIgnore`; NotificationTextsTest `unknownTypeShowsNothing` | |
|
||||
| AC-14.1 | Analytics are computed on the phone from Room, offline, with pending changes | Done | `data/.../repo/AnalyticsRepositoryImpl.kt:33-55`; RepositoriesTest `analytics_includePending_excludeDeleted`; AnalyticsViewModelTest `offlineWorksAndCountsPendingChanges` | |
|
||||
| AC-14.2 | Range presets and custom; "last N months" is whole months; default `last_6_months` | Done | `domain/.../Analytics.kt:82-104`; vectors `contracts/test-vectors/resolve_range.json` (VectorTest); ResolveRangeTest; AnalyticsRulesTest `defaultsAreThoseOfAc14` | |
|
||||
| AC-14.3 | Granularity month or ISO week (Monday start); default month | Done | `Analytics.kt:36-47,108-113`; spend_series vectors | |
|
||||
| AC-14.4 | Measure household or my share; default household | Done | `Analytics.kt:53-56`; `AnalyticsRepositoryImpl.kt:95-96` | |
|
||||
| AC-14.5 | Group by category or account; default category | Done | `Analytics.kt:57-60`; AnalyticsRulesTest `defaultsAreThoseOfAc14` | |
|
||||
| AC-14.6 | Chart 1 stacked bars; chart 2 by category; chart 3 by account (sorted descending, with amount and percent); total above; settlements never counted | Done | `feature/analytics/.../AnalyticsScreen.kt:172,188-191,217-225`; `AnalyticsRepositoryImpl.kt:43-44` (sorted descending); `Analytics.kt` reads expenses only | |
|
||||
| AC-14.7 | Every bucket appears, empty ones as zero; edge buckets clipped to the range | Done | `Analytics.kt:35-48,65-69`; spend_series vectors (13 cases) | |
|
||||
| AC-14.8 | Tapping a bar or a row opens the expenses behind it | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` `Routes.DRILL_DOWN`; `AnalyticsRepositoryImpl.kt:57-86`; DrillDownViewModelTest | |
|
||||
| AC-14.9 | Deleted expenses excluded; archived accounts and categories included | Done | `AnalyticsRepositoryImpl.kt:91-93` (`liveExpenses`); RepositoriesTest `analytics_includePending_excludeDeleted` | |
|
||||
| AC-14.10 | Percent rounded half-up to one decimal | Done | `core/design/.../chart/Charts.kt:255-267` (`MoneyFormatter.percent`); MoneyFormatterTest `percentIsHalfUpToOneDecimal` | |
|
||||
| AC-15.1 | Full local copy of users, accounts, categories, templates, expenses, settlements, audit | Done | `data/.../db/Entities.kt`, `KvittDatabase.kt`; SyncEngineTest `pull_*` | |
|
||||
| AC-15.2 | Expenses and settlements work offline; other actions are disabled offline with "Needs connection" | Done | `data/.../connectivity/Connectivity.kt` (`OnlineGate`); RepositoriesTest `onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing`, `onlineOnly_withinThirtySecondsOfANetworkError_isOffline`; `NeedsConnectionHint` in CategoryListScreen, SyncStatusScreen; StateHolderTest `profileOfflineDisablesEverything` | |
|
||||
| AC-15.3 | Sync on start, on foreground, 2 s after a write, every 15 min (network constraint), and on FCM (expedited); one at a time; names `kvitt-sync` APPEND_OR_REPLACE and `kvitt-sync-periodic` KEEP | Done | `SyncTriggers.kt:49-64,90-115`; `app/.../AppLifecycle.kt:37-50`; `SyncEngine.kt:55` (Mutex); SyncTriggersTest (4 tests), SyncEngineTest `oneCycleAtATime` | The WorkManager wiring itself is not unit-tested (a fake `SyncWork` is used) |
|
||||
| AC-15.4 | Home shows "Last synced" and the pending count; sync status screen | Done | `core/design/.../component/Sync.kt` (`SyncStatusPill`); `feature/home/.../SyncStatusScreen.kt`; HomeScreenshotTest | |
|
||||
| AC-15.5 | LWW per row, deletes final, rejected changes shown | Done | `SyncEngine.kt` (applyResults); SyncEngineTest `push_rejectedStale_*`, `push_rejectedWithoutCurrent_*`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | |
|
||||
| AC-15.6 | 14 days offline with 200 changes syncs everything (scripted clock) | Done | `data/src/test/.../sync/OfflineFourteenDaysTest.kt:44` `ac15_6_fourteenDaysOffline_twoHundredChanges_allSyncOnReconnect` | |
|
||||
| §14.4.5-1 | Pending rows by `local_updated_at`, up to 100, `mutation_id = pending_mutation_id` | Done | `SyncEngine.kt:163-175,390`; SyncEngineTest `push_batchesOf100_oldestLocalChangeFirst_withExactlyTheClientWritableFields`, `push_mutationIdIsThePendingMutationId` | |
|
||||
| §14.4.5-2 | Network error or 5xx: retry with backoff; 401 revoked/unauthorized; 429 retry; 426 stop; whole-request refusal goes on to pull and records `last_error_code` | Done | `SyncEngine.kt:125-160`; SyncEngineTest `push_networkError_*`, `push_serverError_asksForRetry`, `deviceRevoked_wipesTokenAndLocalData`, `unauthorized_keepsData_*`, `protocolUnsupported_stopsSyncingUntilUpdated` | No test covers the 429 path or the D-040 whole-request refusal (400/413/409/422) |
|
||||
| §14.4.5-3 | Apply accepted results, rejections with equal content, other rejections, and `clock_skew` with the D-094 re-stamp | Done | SyncEngineTest `push_accepted_*` (2), `push_rejectedWithEqualContent_isTreatedAsAccepted`, `push_rejected_whileEditedAgain_*`, `push_clockSkew_*`, `push_afterClockSkew_restampsRowsStampedAhead_*`, `push_withoutAClockSkewFlag_neverRestamps` | |
|
||||
| §14.4.8 | Epoch change and `cursor_ahead` lead to recovery; on 401 Unrecognised the data is kept; a re-pair by the same user keeps the data and re-pushes, another user wipes | Done | `SyncEngine.kt:314`; `data/.../repo/SessionRepositoryImpl.kt:66-72`; SyncEngineTest `epochChange_*`, `cursorAhead_recoversLikeANewEpoch`; SessionRepositoryTest | |
|
||||
| AC-16.1 | Server hosts the latest APK and metadata | Done | `server/crates/kvitt-server/src/api/apk.rs`; server test `core_api/apk.rs` | Server side, see fork A |
|
||||
| AC-16.2 | Release builds check E-5 on cold start and at most every 6 h while resumed; banner with version name | Done | `AppLifecycle.kt:44-47`; `data/.../update/Updater.kt:84-113,166`; UpdaterTest `checksAtMostEverySixHours`, `newerVersionIsAvailable_sameIsNone`; `feature/home/.../HomeScreen.kt:173,240` | |
|
||||
| AC-16.3 | Download to cache, verify size and SHA-256, install via PackageInstaller; `NeedsInstallPermission` emitted once | Done | `Updater.kt:39-57,117-144,177-196`; UpdaterTest `verifiedDownloadIsInstalled`, `theInstallPermissionIsExplainedOnceBeforeTheFirstInstall`; `HomeScreen.kt:276-287` | The installer is behind a fake in tests |
|
||||
| AC-16.3-device | The real PackageInstaller session and the system "Install unknown apps" prompt | Not verifiable without device | `Updater.kt:177-209` | Needs a signed release APK published on a server (M10) |
|
||||
| AC-16.4 | A verification failure deletes the file and shows "Download failed, try again" | Done | `Updater.kt:137-141`; UpdaterTest `shaMismatchDeletesTheFile_andFails`, `sizeMismatchDeletesTheFile_andFails`; golden S-17 failed | |
|
||||
| AC-16.5 | Debug builds never check | Done | `Updater.kt:85`; `AppLifecycle.kt:46`; UpdaterTest `debugBuildsNeverCheck` | |
|
||||
| AC-16.6 | "Check for update" gives UpToDate, Failed or Available, with copy; debug gives UpToDate without a request | Done | `feature/settings/.../about/AboutViewModel.kt:43-59`; UpdaterTest `checkResult_tellsUpToDateFromAFailure` | In debug builds the button is hidden (`MainActivity.kt:35` `updateCheckAvailable = !BuildConfig.DEBUG`) rather than reporting UpToDate. This is consistent in effect |
|
||||
| AC-19.1 | Every string is a resource in en and sv; no literal in feature code; the gate fails on a missing key | Done | `build-logic/.../AndroidCommon.kt:14-16` (`MissingTranslation` as error, `abortOnError`); StringsCatalogueTest; OnboardingStringsTest | Nothing in the gate stops a Kotlin string literal in a feature. A grep for `Text("`, `text = "` and `contentDescription = "` in `feature/*/src/main` found none |
|
||||
| AC-19.2 | Money and dates formatted per §9.8 | Done | MoneyFormatterTest, DateFormatterTest (`core/design/src/test/...`) | |
|
||||
| AC-19.3 | Default category names and user-visible enums exist in both languages | Done | StringsCatalogueTest `theFourteenDefaultCategoryNamesAndTheNotificationCopyArePresent`; lint `MissingTranslation` | |
|
||||
| S-11 | Change filters (range, granularity, measure, group by, custom) | Done | `AnalyticsViewModel.kt`; AnalyticsViewModelTest `togglesReQueryAtOnce`, `customOpensThePickerAndAppliesOnlyWithBothDates` | |
|
||||
| S-11 | Tap a bar or a row to open S-12 | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` ANALYTICS to DRILL_DOWN | |
|
||||
| S-11 must-show | Total card, legend, empty state, skeleton loading, no error state | Done | `AnalyticsScreen.kt:172-225`; AnalyticsViewModelTest `firstSyncIsLoading`; AnalyticsRulesTest `loadingOnlyBeforeTheFirstSync` | |
|
||||
| S-12 | Open expense | Done | `DrillDownScreen.kt:61-68`; `KvittApp.kt` DRILL_DOWN `onOpenExpense` to Routes.expense | |
|
||||
| S-13 | Navigate (Profile, Categories, Recurring, Devices, Invite or Pair (admin), Sync status, About) | Done | `feature/settings/.../hub/SettingsHubScreen.kt:98-118`; `SettingsHubViewModel.kt:45-49`; `KvittApp.kt` SETTINGS block; StateHolderTest `hubCountsAndRows` | |
|
||||
| S-14 | Save (name), change or remove the picture (photo picker), offline state | Done | `profile/ProfileScreen.kt:59-75,117`; `SettingsRepositories.kt:77-83`; StateHolderTest `profileNameRulesAndSave`, `profileOfflineDisablesEverything` | |
|
||||
| S-15 | Add | Done | `KvittApp.kt` CATEGORIES `onNewCategory`; StateHolderTest `newCategoryNeedsAName` | |
|
||||
| S-15 | Edit (name with "Reset name", 33 icons, 14 swatches) | Done | `categories/CategoryEditorScreen.kt`; StateHolderTest `categoryResetNameSavesNull`; `SettingsRepositories.kt:105-106` | |
|
||||
| S-15 | Reorder (drag) | Done | `categories/CategoryListScreen.kt:98-101,164-179` (long press and handle drag, plus move up/down); `SettingsRepositories.kt:108-119` (one PATCH per changed `sort_order`); StateHolderTest `categoryReorderFailureRestoresTheOrder` | No `:data` test covers `CategoryRepositoryImpl.reorder` |
|
||||
| S-15 | Archive | Done | `CategoryEditorViewModel.kt`; StateHolderTest `archiveReturnsWithASnackbarForTheList` | |
|
||||
| S-15 | Delete, with the reason when not allowed | Done | `CategoryEditorViewModel.kt:29-42`; StateHolderTest `categoryDeleteAffordanceFollowsDeletable` | |
|
||||
| S-16 | Sync now (online only) | Done | `feature/home/.../SyncStatusViewModel.kt:35-36,51-52`; `SettingsRepositories.kt:305-307` | |
|
||||
| S-16 | Open a rejected change (read-only) | Partial | `SyncStatusScreen.kt` RejectionCard (around line 274): opens S-8/S-10 for `rejection.entityId` | Opens the normal editable detail of the current local row, which holds the server's winning version (or "missing" when `current` was null). The lost payload in `sync_rejections.rejected_payload` is never shown, so "what was lost" is limited to the summary line |
|
||||
| S-16 | Dismiss it | Done | `SyncStatusScreen.kt` (around line 298) dismiss button; `SyncStatusViewModel.kt:56-57`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | |
|
||||
| S-16 must-show | Clock-skew warning, "Sync failed", stale copy naming `Rejection.actor` | Done | `SyncStatusScreen.kt:141`; `SyncStatusViewModel.kt:29`; RepositoriesTest `syncStatus_aRejectionNamesWhoMadeTheServersVersion` | |
|
||||
| S-17 | Update | Done | `HomeScreen.kt:115-119,240-241`; `AboutViewModel.kt:61` | |
|
||||
| S-17 | Later (hides the banner for the ViewModel's lifetime) | Done | `HomeViewModel.kt:68,88` | |
|
||||
| S-17 must-show | Version name, "Install unknown apps" dialog once, progress, failure | Done | `HomeScreen.kt:154,276-287`; goldens S-17 update, progress and failed | |
|
||||
| S-18 | Pair again (goes to S-1); back closes the app | Done | `KvittApp.kt` REMOVED (`BackHandler { activity?.finish() }`, `replaceAll(Routes.PAIRING)`) | |
|
||||
| S-19 | Add | Done | `KvittApp.kt` RECURRING `onNewTemplate`; StateHolderTest `newTemplateDefaultsAndSave` | |
|
||||
| S-19 | Edit | Done | `KvittApp.kt` TEMPLATE_EDITOR; StateHolderTest `editTemplateLoadsSplitAndMarksServerErrors` | |
|
||||
| S-19 | Pause/Resume | Done | `RecurringEditorViewModel.kt:70,187` (switch plus resume hint) | Only from inside the editor; the list has no direct pause action. The spec does not require one |
|
||||
| S-19 | Delete | Done | `RecurringEditorViewModel.kt:221,250-254` | |
|
||||
| S-20 | Revoke (with confirmation) | Done | `devices/DevicesViewModel.kt:89-103`; `canRevoke` (AC-3.2); StateHolderTest `devicesLoadFailAndRevoke`; PureRulesTest `revokeRules` | Revoked devices are filtered out of the list (`DevicesViewModel.kt:76`) |
|
||||
| S-21 | Create (partner before the join; device for either user) | Done | `invites/InvitesViewModel.kt` `invitePartner`, `inviteDevice`; `InvitesScreen.kt:185-186`; StateHolderTest `invitesCreateAndRefuse` | Admin only (`SettingsHubViewModel.kt:45-49`), as E-12 requires |
|
||||
| S-21 | Share (Android share sheet) | Done | `InvitesScreen.kt:80-91` (`ACTION_SEND`, `createChooser`) | |
|
||||
| S-21 | Cancel a pending invite | Done | `InvitesViewModel.kt` `askCancel`, `cancel` | |
|
||||
| S-21 must-show | Code in groups of 4, link, expiry | Done | `InvitesScreen.kt:192-214` | |
|
||||
| S-22 | Check for update | Done | `about/AboutScreen.kt:106-117`; `AboutViewModel.kt:43-59` | Hidden in debug builds |
|
||||
| S-22 must-show | Version name and code, server URL, server version "(from /health)" | Partial | `data/.../repo/SettingsRepositories.kt:336-345`; `data/.../api/KvittApi.kt:241` (`X-Kvitt-Server-Version`) | The server version comes from the `X-Kvitt-Server-Version` response header of the last request, not from E-1 `/health` as §13.3 states. It is null until a request reaches the server. The value is the same crate version |
|
||||
| §14.2.4 | UI contract types and interfaces in `:core:model` match the spec block | Done | `core/model/src/main/kotlin/se/hagstedt/kvitt/model/*.kt` (Ids, Enums, Records, Session, Home, Ledger, Accounts, Expenses, Settlements, Analytics, Settings, Sync, Push); `core/model/src/test/.../ContractTest.kt` | Every type, member, parameter and enum order checked matches, including the v0.13 additions (Pairing, lastAttemptFailed, Rejection.actor, appVersionName, NewRecurringTemplate, UpdateCheck, untouched, PushEvent). No differences found |
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
> Taken at `18c527f` (main, 2026-10-18, SPEC v0.17); committed verbatim with SPEC v0.18. Status of each finding: SPEC §11.4.
|
||||
|
||||
# Security review of Kvitt at `18c527f` (2026-10-18)
|
||||
|
||||
Read-only review of the main checkout against SPEC v0.17 §8, §10, §14.3, §14.4, §14.6 and general practice. Scope: `server/crates/**`, `android/**`, `infra/**`, `contracts/openapi.yaml`, `.gitea/workflows/**`. No build, no phone, no network, no repository change. Threat model as in SPEC §8.1: one public port (8443) on a home connection, two trusted users, attacker is an internet stranger or a lost phone. Aggregates only; no secret values were read.
|
||||
|
||||
## 1. Verdict
|
||||
|
||||
**No critical finding. One High: the in-memory rate limiter can be made to grow without bound and to bypass the per-IP limit by an unauthenticated stranger (DoS of the Pi). Fix it before port 8443 is opened.** Everything else is Medium or below, mostly hardening and owner decisions. The design (hashed 256-bit device tokens, 60-bit single-use invite codes with a 10-per-hour failure lockout, Keystore-sealed token on the phone, no backup/extraction, one exported activity, parameterised SQL everywhere, path-safe APK and picture files, read-only non-root server container behind Caddy) matches the spec and is sound.
|
||||
|
||||
Counts: Critical 0, High 1, Medium 3, Low 10, Info 8.
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### High
|
||||
|
||||
**H-1. Rate limiter: a random bearer token becomes "accepted" on any non-401 response, bypasses the IP limit and is never pruned.**
|
||||
Location: `server/crates/kvitt-server/src/api/rate_limit.rs:273-287` (admit), `:305-314` (record), `:143-144` (prune), with `src/router.rs:102` (`require_protocol` runs before the token check) and `src/auth.rs:65-76`.
|
||||
Issue: for an authenticated route the limiter keys by the hash of any token-shaped bearer (`Key::Token`). `admit` only *checks* the IP window for a not-yet-accepted token, it does not count against it; `record` counts the IP only when the response is 401, and otherwise marks the token `accepted = true`. A request with a random `kvt_` token and **no `X-Kvitt-Protocol` header** gets 426 from `require_protocol` (before the database is touched), so the random token is marked accepted; `prune` keeps accepted tokens forever. The same happens with 405 (wrong method on a known path).
|
||||
Exploit (internet stranger, no credentials): send `GET /me` with a fresh random token and no protocol header in a loop. No IP counter is ever incremented, so there is no rate limit at all on this path, and every request adds a permanent `HashMap` entry (about 150 bytes) to a container limited to 160 MiB: a few hundred thousand requests, well within an hour on a home uplink, exhaust the server's memory (OOM kill, restart loop). Secondary effect: each such "accepted" token then gets its own 600/min bucket of database lookups on later requests with the protocol header, so the per-IP 60/min limit of §8.6 no longer bounds the database load from one IP.
|
||||
Smallest fix (server): in `record`, mark a token accepted only when `status.is_success()` (2xx), not on every non-401/429; in `admit`, for a not-yet-accepted token *hit* the IP window (count it) instead of only checking it; in `prune`, also drop accepted tokens whose window has expired (re-marking costs one successful request). Add a test: random token + no protocol header × N leaves `tokens.len()` bounded and returns 429 after 60.
|
||||
|
||||
### Medium
|
||||
|
||||
**M-1. Backups on Google Drive are unencrypted and the rclone token has full-Drive scope.**
|
||||
Location: `infra/backup/backup.sh:55-72`; SPEC §8.7 last bullet vs §8.1 ("a compromised backup location" is in scope); SPEC §10.9 (`scope = drive`).
|
||||
Issue: the daily `kvitt-*.sqlite.gz` (all expenses, notes, names) and pictures go to Drive in clear; the mounted `rclone.conf` carries an OAuth refresh token with the `drive` scope, i.e. read/write to the owner's entire Drive, not just the backup folder. A compromise of the backup container or of `/opt/kvitt/secrets/rclone.conf` exposes the whole Drive; a compromise of the Google account exposes every backup. The spec accepts the first half (§8.7) while listing the second half as a threat (§8.1).
|
||||
Smallest fix (infra + owner decision): wrap the remote in an rclone `crypt` remote (`[gdrive-crypt] type = crypt, remote = gdrive:<folder>`; the two passwords live in the already-secret `rclone.conf`, and `RESTORE.md` gains one `rclone copy` through the crypt remote); re-authorise with scope `drive.file` so the token reaches only files rclone created. Needs a decision entry and a §8.7/§10.9 edit.
|
||||
|
||||
**M-2. The cert-sync SSH key may be a root login on the Home Assistant Pi.**
|
||||
Location: `infra/cert-sync/sync.sh:36-42`; SPEC §10.5.1 ("variant B: added as a plain key"), `.env.example` ("the Home Assistant SSH add-ons log in as root").
|
||||
Issue: cert-sync holds `/secrets/ha_ssh_key`. With variant B there is no `restrict,command=` on the HA side, so whoever holds the key (a compromised cert-sync container, or the key copied around on the Mac and the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443 and the house's automation. The script's own fixed `tar` command does not restrict the server side.
|
||||
Smallest fix (infra + owner): make variant A the only accepted variant (`restrict,command="tar -C <dir> -cf - fullchain.pem privkey.pem"`, plus `from="<Kvitt Pi LAN address>"`), and if the add-on cannot do that, use a dedicated non-root user that can read `/ssl` only. Record the variant in `infra/README.md` as §10.5.1 already asks.
|
||||
|
||||
**M-3. Picture decoding can exceed the server's memory limit (8000 × 8000 px vs 160 MiB).**
|
||||
Location: `server/crates/kvitt-server/src/api/pictures.rs:39` (`MAX_DIMENSION`), `:66-92` (`process`), `infra/pi/compose.yaml` (`mem_limit: 160m`); limit from SPEC AC-4.4.
|
||||
Issue: the dimension check happens before decoding (good), but an 8000 × 8000 image is allowed: decoding to RGBA is 256 MB, plus `to_rgba8`/`to_rgb8` copies, on a container limited to 160 MiB. A 5 MiB PNG or WebP easily encodes that size (a flat colour compresses to a few KB). Only an authenticated device can call `PUT /me/picture`, so in this threat model the attacker is a lost phone with a live token (until revoked) or a bug in the app, not a stranger; the effect is a crash and restart of the server (and of the sync for both phones) on each upload.
|
||||
Smallest fix (server, plus a spec number): decode through `image::ImageDecoder` limits (`Limits { max_image_width/height, max_alloc ~ 64 MiB }`) or lower the limit to a pixel count (for example 16 M pixels, 4000 × 4000) and refuse larger with `image_invalid`. The number is a spec value: `TODO(spec)` / decision entry.
|
||||
|
||||
### Low
|
||||
|
||||
**L-1. Caddy's access log records invite codes and the admin's display name.**
|
||||
Location: `infra/pi/Caddyfile:11-14`; SPEC §8.7 (the Rust server logs the route template, by design, `src/router.rs:134-142`).
|
||||
Issue: Caddy logs `request.uri` in full: `/i/<CODE>?from=<name>` and the `?from=` value, together with the client IP and User-Agent, into the json-file logs on the Pi (10 MB × 3). Codes live 72 h and are single-use, so the risk is low (an attacker with Pi log access is out of scope), but it is personal data and a secret in a log that §8.7 says must not hold either.
|
||||
Fix (infra): add `log_skip` (Caddy ≥ 2.8) for the `/i/*` matcher, or `log { format filter { request>uri delete } ... }` — one line in the Caddyfile; or drop the access log entirely (the server already logs every request).
|
||||
|
||||
**L-2. The Caddy container is not hardened like the other three.**
|
||||
Location: `infra/pi/compose.yaml` (`caddy` service: no `user`, no `cap_drop`, no `read_only`); SPEC §8.9 hardens only the server.
|
||||
Issue: Caddy runs as root with every default capability and a writable root filesystem, and its admin API is bound to `0.0.0.0:2019` for every container on the `kvitt` network (only cert-sync needs it). This is the process that terminates TLS from the internet.
|
||||
Fix (infra): `cap_drop: [ALL]`, `cap_add: [NET_BIND_SERVICE]` (or none: 8443 is unprivileged), `read_only: true` with `tmpfs: [/tmp]` and `caddy_data`/a `caddy_config` volume writable; keep `no-new-privileges`. Admin on `0.0.0.0` is required for cert-sync; acceptable on the internal network.
|
||||
|
||||
**L-3. APK download is unauthenticated and 11 MB per request at 60 requests per minute per IP.**
|
||||
Location: `server/crates/kvitt-server/src/api/apk.rs:76-101`, `src/api/rate_limit.rs:56-63`; SPEC §8.6, §10.6.
|
||||
Issue: the only large unauthenticated response. One IP may pull about 660 MB/min, several IPs more, saturating the home uplink (and the Pi's SD card) for as long as they like. The file is public by design (the landing page links it).
|
||||
Fix (server; the number is a spec value): a separate, lower window for `/apk/files/{file_name}` (for example 5 per minute per IP) — `TODO(spec)`.
|
||||
|
||||
**L-4. `fcm_token` has no length limit.**
|
||||
Location: `server/crates/kvitt-server/src/api/devices.rs:95-117`; SPEC §9.6 has no limit for it.
|
||||
Issue: an authenticated device can store a string of up to 1 MiB, which is then sent to FCM in every push body for that device (and kept in the database and backups). Trusted users only; a Firebase Installation ID is 22 characters.
|
||||
Fix (server, spec number): refuse more than, say, 256 characters with `validation_failed` (`TODO(spec)`).
|
||||
|
||||
**L-5. The development stack mounts every secret into the server container.**
|
||||
Location: `infra/dev/compose.yaml` (`${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro`); SPEC §10 (accepted as the dev layout).
|
||||
Issue: the dev server container sees the HA SSH key, `rclone.conf`, the Gitea push key and `google-services.json`, although it needs only `fcm-service-account.json`. The Pi stack mounts the single file (correct).
|
||||
Fix (infra): mount `${KVITT_SECRETS_DIR}/fcm-service-account.json:/secrets/fcm-service-account.json:ro` as on the Pi; a missing file is already handled (push disabled).
|
||||
|
||||
**L-6. Any link can prefill any server URL on the pairing screen.**
|
||||
Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt:53-56`, `android/feature/onboarding/.../InviteLink.kt:36-52` (`fromAppLink`, `fromLandingLink`), manifest intent filters `android/app/src/main/AndroidManifest.xml:26-47`; SPEC AC-2.6, D-084.
|
||||
Issue: `kvitt://invite?server=https://attacker&code=…` from any app or web page, or `https://<anyhost>/i/<CODE>`, opens S-1 with the attacker's server in the field. An unpaired phone (first install, or after a revoke) could be talked into pairing with a rogue server, which then receives everything the phone pushes. Mitigations in place: a paired phone ignores the link (D-084, the session gate only shows S-1 when unpaired); the URL is visible and editable; release builds require `https://`.
|
||||
Fix (android, small): on S-1 show a warning line when the prefilled server is not the default `https://icehack.asuscomm.com:8443` ("Unusual server"); no server change.
|
||||
|
||||
**L-7. Notification text (description, amount) shows on the lock screen.**
|
||||
Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt:82-84` (no `setVisibility`/`setPublicVersion`).
|
||||
Issue: with Android's default "show sensitive content" setting a bystander reads expense descriptions and amounts from a locked phone. Spec §13.3 defines the copy; it does not say what the lock screen shows.
|
||||
Fix (android): `setVisibility(VISIBILITY_PRIVATE)` with `setPublicVersion(...)` carrying the generic text already defined for the sync-failed case.
|
||||
|
||||
**L-8. The Gitea workflow expands the registry token inside a shell line.**
|
||||
Location: `.gitea/workflows/build-images.yaml` ("Log in to Gitea Container Registry": `echo "${{ secrets.kvitt_cicd }}" | docker login …`).
|
||||
Issue: `${{ }}` is substituted into the script text before the shell runs it: a token containing a quote or `$` breaks the line (and could inject), and the value appears in the step's process arguments on the runner. The token also needs `write:repository` (for the tag push) on top of `write:package`.
|
||||
Fix (infra): pass it as `env: { REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }} }` and use `printf '%s' "$REGISTRY_TOKEN" | docker login …`; keep the scopes minimal (the tag push can use the checkout token if Gitea provides one).
|
||||
|
||||
**L-9. Images are pulled by tag over plain HTTP from the LAN registry.**
|
||||
Location: `infra/pi/compose.yaml` (`${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`), `infra/pi/install.sh` (`insecure-registries`), `infra/pi/deploy.sh`; SPEC §10.3, D-001/D-104 (LAN is trusted).
|
||||
Issue: anyone on the home Wi-Fi who can answer for `gitea.lan` (or the Gitea Pi itself, if compromised) can hand the Kvitt Pi a tampered server image that sees every token hash and all data. Accepted by the spec's trust in the LAN; noted because the server is the one component that holds everything.
|
||||
Fix (infra, optional): have `deploy.sh` pin the digest the workflow printed (`image@sha256:…` in `.env`), or enable TLS on Gitea later.
|
||||
|
||||
**L-10. A member can tell whether a device id belongs to the other user.**
|
||||
Location: `server/crates/kvitt-server/src/api/devices.rs:57-63` (404 for unknown, 403 for another user's device).
|
||||
Issue: trivial information leak between the two trusted users; listed for completeness.
|
||||
Fix (server): return 404 in both cases, or leave as is.
|
||||
|
||||
### Info
|
||||
|
||||
**I-1.** `X-Kvitt-Server-Version` on every response and `GET /health` (`server_version`, `schema_version`, `db_ok`) are visible to unauthenticated scanners (`src/router.rs:125-131`, `src/api/health.rs`). Spec-mandated; the version is not a secret. Accept.
|
||||
|
||||
**I-2.** Room database and Coil's picture cache are unencrypted at rest (`android/data/.../di/DataModule.kt`, `repo/Pictures.kt`); the token key is Keystore AES-GCM without `setUnlockedDeviceRequired`/StrongBox (`data/auth/TokenCipher.kt:50-58`). This relies on Android file-based encryption and the lock screen, as §8.2 intends; the lost-phone case is covered by revocation (AC-3.3). Accept.
|
||||
|
||||
**I-3.** The landing page sends no `Content-Security-Policy`/`X-Content-Type-Options` (`src/api/landing.rs:196-210`). Output is escaped and the page is static with inline CSS only; `Cache-Control: no-store` and `Referrer-Policy: no-referrer` are set. Optional one-liner: `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'`.
|
||||
|
||||
**I-4.** No vulnerability scan of Rust dependencies is part of the server gate (no `cargo audit`/`cargo deny` in `tools/` or the hooks); `tools/dependency-report/latest.py` covers the Android catalog only. This review did not run one (offline). Suggest `cargo audit` as a periodic owner step or in the gate.
|
||||
|
||||
**I-5.** Client-IP correctness to verify after deploy: the rate limiter trusts the last `X-Forwarded-For` entry from Caddy (`rate_limit.rs:203-227`). If Docker's userland proxy ever fronts port 8443 (IPv6 without `ip6tables`, or `userland-proxy` quirks), Caddy sees the bridge gateway for every client and all internet clients share one 60/min bucket and one 10-failed-accepts lockout. Check once in Caddy's log that `request.remote_ip` is a public address.
|
||||
|
||||
**I-6.** `bootstrap` and `pair-device` print the invite code to stdout (`src/cli/bootstrap.rs:41-62`). Through `docker compose run --rm` the line also sits in that one-off container's json log until the container is removed. Expected; the owner reads it and it expires in 72 h.
|
||||
|
||||
**I-7.** The Ktor/OkHttp client keeps default redirect handling (`data/api/KvittApi.kt:246-248`). Neither the server nor Caddy redirects, and TLS excludes a third party, so the bearer cannot be bounced to another host. Fine.
|
||||
|
||||
**I-8.** `infra/secrets/` on the Mac: directory mode 0700; private key files and the service-account, rclone and `google-services.json` files mode 0600; two `.pub` files 0644. One file (`gitea_push_ed25519`) is not among the secrets SPEC §10 lists; presence only, not read.
|
||||
|
||||
## 3. Checked and found fine
|
||||
|
||||
* **Tokens (§8.2):** 32 CSPRNG bytes via `getrandom`, `kvt_` + base64url, stored only as SHA-256 hex (`api/invites.rs:79-86`, `auth.rs:46-53`); lookup by `UNIQUE token_hash` (`migrations/0001_init.sql:29`); revoked → `device_revoked` before any work; token never logged (log line carries route template, status, latency, device id only, `router.rs:134-162`); the hourly touch is in a `BEGIN IMMEDIATE` transaction.
|
||||
* **Invite codes (§8.3, AC-2.3, AC-2.12):** 60 random bits from the CSPRNG, Crockford alphabet, normalisation and hashing as specified; single-use/expiry/cancel checked inside the write transaction; every failure is the same `invite_invalid`; 10 failed accepts per IP per hour then 429 (`rate_limit.rs:46-49, 251-265`); `admin_setup` refused once a user exists; `household_full` enforced.
|
||||
* **Authorization (§8.4):** `AdminDevice` on create/list/cancel invites; devices listing filtered for members; revoke rules and `cannot_revoke_current_device`; `/me` edits only the caller; pushed expenses/settlements must name household users (`sync/check.rs:92-110, 185, 204-222`).
|
||||
* **SQL:** every query uses `sqlx` bind parameters; no string-built SQL found in `server/crates`; path ids must be canonical hyphenated UUIDs (`api/mod.rs:95-113`).
|
||||
* **Files and paths:** APK names must match `^kvitt-[0-9]+\.apk$` and be regular files (`apk.rs:22-26, 80-92`); picture paths are built from a parsed UUID and an integer version; writes are atomic (temp + rename); `latest.json` is read on each request, never cached stale.
|
||||
* **Body limits:** 1 MiB default and 5 MiB for `PUT /me/picture` in the router; Caddy `max_size 6MB`; sync batch 1 to 100 mutations, pull page ≤ 1000; notes/description/name limits applied after trimming; control characters refused in notes.
|
||||
* **Error leakage:** `internal` returns only a request id to the client and logs the detail (`error.rs:225-234`); 404 for unknown paths; serde messages reach the client only for the caller's own malformed JSON.
|
||||
* **Panics/unsafe:** `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` in `lib.rs` and `main.rs`; `unsafe_code = "forbid"` in the workspace; mutex poisoning handled (`rate_limit.rs:164-169`).
|
||||
* **Push (§14.6):** data-only payloads with ids only; service-account `Debug` never prints the key (`push/fcm.rs:64-71`); access token cached in memory only; `UNREGISTERED` clears the token only if unchanged; log lines carry device id and push type, never the FCM token.
|
||||
* **Server container (§8.9):** uid 10001, `read_only`, `cap_drop: [ALL]`, `no-new-privileges`, no published port, distroless runtime pinned by digest, single secret file mounted read-only; only Caddy publishes 8443; TLS from files, `auto_https off`, admin API not published; Caddy ≥ 2.11 defaults give TLS 1.2+ and the `Authorization` header is redacted (verified by the owner 2026-10-18 per §10.5.2).
|
||||
* **cert-sync:** `StrictHostKeyChecking=yes` with a dedicated known_hosts, `BatchMode`, `IdentitiesOnly`, fixed remote command, PEM/key-match/expiry checks before install, atomic rename, uid 1000, `cap_drop: [ALL]`.
|
||||
* **Backups:** online `.backup` + `integrity_check`; copy-only with `--immutable`, confirmed by `lsf` before local deletion; `rclone.conf` used through a 0600 `mktemp` copy that is removed on exit; log lines carry names and sizes only.
|
||||
* **Secrets in git:** `.gitignore` covers `local.properties`, `*.jks`, `infra/secrets/`, `*.pem`, `*service-account*.json`, `google-services.json`, `*.sqlite*`, `infra/pi/.env`, `infra/pi/out/`, `infra/pi/secrets/`; the pre-commit hook refuses those patterns; `git ls-files` and the add-history of all branches contain no secret-looking file; `.env.example` files hold placeholders only (the Drive folder id is not a secret).
|
||||
* **Android (§8.5, §8.8):** release network config: cleartext off, system anchors; pinned-root variant limited to one domain and only with `kvitt.tls.pinnedRoot=true`; debug cleartext confined by `normaliseServerUrl` to `https://`, `localhost`, `10.0.2.2` and the three private ranges (`SessionRepositoryImpl.kt:102-124`); `allowBackup="false"` and extraction rules excluding every domain for both cloud backup and device transfer; exported components: `MainActivity` only; `KvittMessagingService` and `InstallResultReceiver` not exported; WorkManager default initializer removed in favour of the Hilt factory.
|
||||
* **Android token storage (§8.2):** Keystore AES-256-GCM, alias `kvitt_token_key`, 128-bit tag, Keystore-generated IV, ciphertext+IV in `auth.preferences_pb`; `Credentials.toString` redacts the token; on `device_revoked` the preferences are cleared, the key deleted, and database, prefs and picture caches wiped (`SessionManager.kt`, `DataModule.kt:166-170`); on `unauthorized` only the token is dropped.
|
||||
* **Android network:** bearer only on authenticated calls and, for Coil, only on URLs under the paired base URL (`PictureAuth.kt`); the token is never put in a URL; the accept call sends no bearer; log statements in `app`, `data` and `adapter` carry status codes and exception class names only.
|
||||
* **In-app update (FR-16):** release-only; file name validated against the same regex as the server; download to app-private cache; size bounded during download and SHA-256 + size verified before `PackageInstaller`; file deleted on any failure; Android enforces signing-key continuity on the update, so a tampered APK from a compromised server could not replace the installed app; `canRequestPackageInstalls` explained once; R8 enabled; `mapping.txt` never served.
|
||||
* **Push handling on the phone:** untrusted FCM data mapped to a typed event with defaults (`PushMapping.kt`), unknown `type`/`v` → sync only; ids are passed to WorkManager as strings and looked up in Room, never rendered raw.
|
||||
* **CSV import:** runs only as an operator CLI against a file path given on the command line, inside one transaction, with header and row validation and a reconciliation gate; not reachable over HTTP.
|
||||
* **OpenAPI:** top-level `security: [device]`; exactly the six public operations (E-1 to E-6) declare `security: []`; the server router applies `AuthDevice`/`AdminDevice` on every other route.
|
||||
|
||||
## 4. Owner questions
|
||||
|
||||
1. **Backups (M-1):** encrypt the Drive backups with an rclone `crypt` remote (passwords in `rclone.conf`, restore documented), and re-authorise rclone with the `drive.file` scope? This changes §8.7's last bullet and §10.9 and needs a decision entry.
|
||||
2. **HA SSH key (M-2):** which `authorized_keys` variant did the Home Assistant add-on accept at checklist step 3? If B (plain key), is a dedicated non-root user possible on that Pi?
|
||||
3. **Picture limit (M-3):** may AC-4.4's 8000 × 8000 become a pixel-count limit (for example 16 M pixels) or a decoder allocation limit, given the 160 MiB container?
|
||||
4. **APK download rate (L-3) and `fcm_token` length (L-4):** are a separate per-IP limit for `/apk/files/*` and a 256-character cap acceptable as new §8.6/§9.6 numbers?
|
||||
5. **After the first deploy (I-5):** one check of Caddy's access log that `request.remote_ip` shows public client addresses, so the per-IP limits key on real clients.
|
||||
Reference in New Issue
Block a user