Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ jobs:
# only the former loads the plugins named in analysis_options.yaml -- so
# every riverpod_lint rule was being skipped here while passing locally
# in an IDE that does load them.
- name: Verify store artwork matches the current UI and assets
run: dart run tool/store_artifacts.dart --check
- run: dart analyze --fatal-infos
- run: flutter test --exclude-tags integration
- name: Test domain logic and refresh UI in Chrome
Expand Down
19 changes: 9 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ on:
default: false
update_priority:
description: >-
Play in-app update priority, 0-5. 0 updates quietly in the
background; 4 and up interrupt testers with a blocking update they
cannot dismiss. Use 4 only when the server can no longer talk to the
version being replaced.
Play in-app update priority, 0-5. The app can offer an immediate
update at 4 or above when Play makes it available and permits the
flow. Use 4 for breaking releases; priority alone does not block
clients or protect data during review or installation.
type: choice
default: "0"
options: ["0", "1", "2", "3", "4", "5"]
Expand Down Expand Up @@ -183,9 +183,9 @@ jobs:
run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" = "$GITHUB_SHA"
# One step, one version: the Worker script and everything in build/web go
# up together and become live together. That is the reason the bundle is
# built in this job rather than deployed from somewhere else — a front end
# that can be newer or older than the API it talks to is a class of bug
# this arrangement simply does not have.
# built in this job rather than deployed from somewhere else. Already open
# web tabs and installed Android builds can still use an older contract;
# API retirement and client update handling must account for those.
#
# The Worker, the D1 database, the KV namespace and the custom domain all
# have to exist already; see docs/runbook.md. Creating them is
Expand Down Expand Up @@ -247,9 +247,8 @@ jobs:
# project's closed track was created by hand and carries its own name.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'alpha' }}
# Play's 0-5 in-app update priority, set per release and only
# settable through the Publishing API. 0 is an ordinary background
# update; 4 and up interrupt testers with a blocking flow, which is
# for a build the server can no longer talk to an older client from.
# settable through the Publishing API. The installed app chooses the
# update flow; priority alone cannot force installation or block use.
PLAY_UPDATE_PRIORITY: ${{ inputs.update_priority || vars.PLAY_UPDATE_PRIORITY || '0' }}
# Written above, from CHANGELOG.md and the committed artwork.
# en-US is the store listing's language; images affect the listing
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ What changed in each version of the app, newest first. The format is
[Semantic Versioning](https://semver.org/), and the file is kept by
[cider](https://pub.dev/packages/cider): see "Versions" in docs/runbook.md.

## [Unreleased]
## [4.0.0] - 2026-10-15
### Changed
- When a numbered API version is retired, saved data stays readable and pending uploads pause. Update OpenSplit through Play or restart into the new web release to edit again.
- This testing release opens a fresh local ledger and requires signing in again. Old device databases remain unused; older builds can no longer change server data.
- The archive option has been removed. Leaving a group ends access on all devices.
- The last account holder can leave a fully settled group, permanently deleting it. Groups with active account holders are retained without an inactivity deadline.
- Groups have larger names and avatars with quieter cover art. Forms use consistent filled fields, and app wording is shorter.

### Fixed
- Departed members cannot open group screens or edit cached data. Confirmed access loss clears the local cache without a retry loop. Stale group and member edits cannot overwrite newer versions.
- Store screenshots and the feature graphic are refreshed together, with CI detecting stale artwork.
- Improved browser database transaction reliability.

### Security
Expand Down Expand Up @@ -100,7 +106,7 @@ What changed in each version of the app, newest first. The format is
### Added
- The first build, to Play internal testing.

[Unreleased]: https://github.com/eigeninteractive/opensplit/compare/v3.1.0...HEAD
[4.0.0]: https://github.com/eigeninteractive/opensplit/compare/v3.1.0...v4.0.0
[3.1.0]: https://github.com/eigeninteractive/opensplit/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/eigeninteractive/opensplit/compare/v2.0.0...v3.0.0
[2.0.0]: https://github.com/eigeninteractive/opensplit/compare/v1.0.0...v2.0.0
Expand Down
50 changes: 29 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Worker over local D1, KV and Durable Object storage. There is no fake server:
the rules they exercise are the server's own. Nothing touches a Cloudflare
account and no credentials are needed.

They skip themselves unless an OpenSplit Worker answers `/api/health`, so
They skip themselves unless the current OpenSplit API answers its health check, so
`flutter test` stays green without one. To use another port (`pnpm exec wrangler dev
--env test --port 8797`), pass `--dart-define=API_BASE_URL=http://127.0.0.1:8797`. CI runs
them in the `backend` job with `--dart-define=REQUIRE_BACKEND=true`, so a
Expand All @@ -160,9 +160,13 @@ dart run drift_dev schema dump lib/data/local/database.dart drift_schemas/
dart run drift_dev schema generate drift_schemas/ test/data/generated_migrations/
```

Until the first public release, an upgrade rebuilds the local database and
re-syncs, which loses anything recorded offline and never pushed. See
[docs/local-database.md](docs/local-database.md).
The local storage baseline opens an `opensplit-v1-<accountId>` database at
schema version 1 and synchronizes it from the server. Old databases are unused;
their offline edits do not transfer. Future upgrades must keep this database
name and migrate local data and queued edits before sync resumes. API retirement
pauses pending uploads; replay through a new API requires that release's
mapping and migration tests. See [docs/local-database.md](docs/local-database.md)
and [docs/api-upgrades.md](docs/api-upgrades.md).

The domain layer is also run in a real browser:

Expand Down Expand Up @@ -302,11 +306,10 @@ package, and only `tool/build_web.dart` may produce a deployable web directory.
It verifies the configuration, builds the client, and then has Workbox precache
the finished release, bundling Firebase's public identifiers into the worker. CI uses structurally valid inert identifiers to prove
the release build. After every CI gate passes, pushes to `main` build with real
production variables, deploy the Worker and the bundle together, and distribute
a signed AAB to Play closed testing. Release reruns the same CI checks before
publishing.
A manual **Release** run with **deploy unchecked** creates
artifacts only, for first-upload bootstrap.
production variables, deploy the Worker and web bundle together, and distribute
a signed AAB and listing artwork to Play closed testing. Release reruns the same
CI checks before publishing. A manual **Release** run with **deploy unchecked**
creates artifacts only, for first-upload bootstrap.

Every integration is off unless configured, and hidden rather than shown broken:

Expand All @@ -318,6 +321,11 @@ Every integration is off unless configured, and hidden rather than shown broken:
| `FCM_VAPID_KEY` | Web push, in addition to the above |
| `LINK_HOST` | The host used in invite links |

The app API uses one base path, defined in `server/src/paths.ts`. OpenAPI
`servers.url` carries it into the generated Dart client; operation paths below
are relative to that base. `API_BASE_URL` configures only the backend origin.
To change the contract version, edit the base path and regenerate the client.

### Exchange rates

Rates are fetched by the server, never by devices — so every member of a group
Expand All @@ -334,7 +342,7 @@ cd server && pnpm exec wrangler secret put EXCHANGERATE_API_KEY --env=""
A daily cron (`0 4 * * *`, after ECB publishes) calls the `Fx` Durable Object,
which is the **only writer** of rates. It holds the history in its own SQLite
and publishes one blob per month to KV, where every device reads it through
`GET /api/fx`.
`GET /fx`.

A singleton object rather than a scheduled function writing KV directly,
because KV is last-write-wins: the daily run and an on-demand backfill
Expand Down Expand Up @@ -362,7 +370,7 @@ already covered would otherwise make the `source` stamped on somebody's
converted expense quietly wrong.

**Backdated entries fetch on demand.** Recording an expense on a date the app
has never priced posts to `/api/fx/backfill`, which fetches that day and caches
has never priced posts to `/fx/backfill`, which fetches that day and caches
it for everyone. The object refuses dates it can already answer, repeats within
the hour, futures, and dates chased more than five times — some days are
unanswerable, and without a ceiling every device retries them forever against
Expand Down Expand Up @@ -439,7 +447,7 @@ project made:
1. **Google Auth Platform → Branding** (formerly the OAuth consent screen).
App name, support email, developer contact. Nothing works until this exists.
2. **Credentials → Create credentials → OAuth client ID → Web application.**
Add `https://<your host>/api/auth/callback/google` as an authorized redirect
Add `https://<your host>/api/v1/auth/callback/google` as an authorized redirect
URI, and your site origin under authorized JavaScript origins. Its
**Client ID** is `GOOGLE_WEB_CLIENT_ID` — one value, used on both platforms,
because the Worker verifies the ID token's audience against the web client
Expand Down Expand Up @@ -486,7 +494,7 @@ The app deliberately does *not* create a session on startup. It used to, and
that broke the arrival it was meant to protect: somebody who already had an
account and tapped an invite link had the single-use token spent by a throwaway
anonymous account, and no way into the group afterwards. `/join/:token` now
reads the link with `GET /api/links/{token}`, which is the one route that runs
reads the link with `GET /links/{token}`, which is the one route that runs
with no session at all, shows what the link is for, and joins only after the
identity question has an answer.

Expand Down Expand Up @@ -517,7 +525,7 @@ Arriving on a group link asks which of the group's unclaimed placeholders you
are, if any. Claiming one is the same single-column update a named invite
performs — no expense is rewritten and no balance moves — and it is what stops
one shared link turning a group of six into a group of twelve.
`GET /api/links/{token}/placeholders` deliberately **requires** a session,
`GET /links/{token}/placeholders` deliberately **requires** a session,
unlike the preview beside it: those are other people's names, which is more than
the token itself implies, and it is asked after an account has been chosen
rather than before.
Expand Down Expand Up @@ -578,7 +586,7 @@ nothing else changes, which is why a deployment with no push configured still
records expenses normally.

**Three kinds wake a device:** an expense, somebody arriving, somebody leaving.
Not renames, archives or links — those belong in the activity feed, which is
Not renames or links — those belong in the activity feed, which is
read on purpose, rather than on a lock screen. `link_created` in particular
would wake a whole group to say that one of them tapped Share.

Expand Down Expand Up @@ -711,7 +719,7 @@ adapter tests:
```bash
pnpm dev --test-scheduled # exposes the handler
curl 'http://127.0.0.1:8787/cdn-cgi/handler/scheduled?cron=0+4+*+*+*'
curl 'http://127.0.0.1:8787/api/fx?since=2026-01-01'
curl 'http://127.0.0.1:8787/api/v1/fx?since=2026-01-01'
```

This reaches the real ECB feed, and with no `EXCHANGERATE_API_KEY` set the log
Expand All @@ -733,11 +741,11 @@ reviewer and a Google OAuth reviewer can all read what the app is. With the
client at the root, the public face would be a sign-in screen, which OAuth
branding verification rejects.

Its pictures are generated rather than drawn. `flutter test
tool/screenshots_test.dart` renders the real screens over demo data into
`site/store/`, and `python3 tool/store_graphics.py` frames one of them as the
link preview and the Play feature graphic; rerun both after a visible UI
change. The pages use the fonts the app bundles, which `tool/build_web.dart`
Its pictures are generated rather than drawn. `dart run
tool/store_artifacts.dart` renders the real screens over demo data into
`site/store/`, then builds the link preview, Play feature graphic and email
wordmark. CI checks the saved source and output hashes, so app changes require
a complete artwork refresh. The pages use the fonts the app bundles, which `tool/build_web.dart`
copies to `/fonts`, so the site asks no other origin for anything.

Nothing in Dart knows about the prefix. `--base-href=/app/` puts it below the
Expand Down
2 changes: 2 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"!server/worker-configuration.d.ts",
// The API contract, emitted from the Worker's Zod schemas.
"!docs/openapi.json",
// Store artwork provenance, emitted after a complete render.
"!site/store/artifacts.json",
// `drift_dev schema dump`.
"!drift_schemas",
// Drift's release file, which CI compares byte for byte.
Expand Down
2 changes: 2 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ targets:
drift_dev:analyzer:
enabled: true
options: &drift_options
# Match AppDatabase.options so schema dumps describe actual storage.
store_date_time_values_as_text: true
sql:
dialect: sqlite
options:
Expand Down
109 changes: 109 additions & 0 deletions docs/api-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# API retirement and app updates

`server/src/paths.ts` defines one numeric `currentApiVersion` and derives the
single supported application API base, `/api/vN`. Positive integer versions
below it are retired and receive HTTP 410 with the stable `update_required`
error and `Cache-Control: no-store`, before reaching account or ledger services.

Unversioned paths, malformed versions, unavailable future versions and unknown
operations within the current version return ordinary `404 / not_found`.
At the v1 baseline, no numbered API has been retired yet. Old unversioned apps
cannot reach data handlers and receive no special upgrade handling.

For example, when the current version becomes 2:

| Request | Result |
| --- | --- |
| `/api/v1/...` | `410 / update_required` |
| Valid `/api/v2/...` operation | Normal handling |
| Unknown `/api/v2/...` operation | `404 / not_found` |
| `/api/v3/...` | `404 / not_found` |
| Unversioned `/api/...` | `404 / not_found` |

The client never guesses incompatibility from an arbitrary 404, redirects an
old request, or changes its own API version.

## Read-only access

One `ApiAccess` flag is shared by the client, local write queue and UI.
An explicit `update_required` refusal turns it on and remembers it in preferences,
keyed by the backend origin and API base. Restarting offline therefore keeps
editing disabled. A compatible app uses the new API base and a fresh flag.
There is no rollback recovery, periodic compatibility probe, extra endpoint or
database schema. Before the first refusal, ordinary offline use still works.

Once refused, the app stops network requests and automatic sync, disables edits,
and keeps saved groups, expenses, payments, history and exports readable. The
persistent notice offers Update OpenSplit. A queued write is neither
acknowledged nor dead-lettered because of retirement; its contents and retry
metadata stay untouched. The queue also checks the flag inside the caller's
transaction so a stale editor cannot commit a new financial edit. Background
sync observes the same refusal and leaves pending work intact.

## Pending edits after an app update

Expenses and their outbox markers are saved together in the local database.
An outbox item identifies a local record; it does not store an HTTP request
addressed to an old API. After updating, the current client reads that record
and builds a request for its own contract.

Preserving an edit during retirement does not guarantee that a newer API will
accept it. A URL or request-format change may need only a new request mapping.
A change to the stored model or its meaning needs a release-specific migration
before synchronization resumes. Future upgrades must retain the same database
name and migrate pending data rather than reset the ledger.

Every breaking release must test upgrading supported old database versions with
pending edits, including relevant creates, updates, deletions and dependencies.
Verify amounts, participants, IDs and queue ordering survive, then exercise the
new API mapping and its success, conflict and refusal responses. No generic
cross-version command format or conversion framework is provided.

If an edit cannot be translated safely, that release needs explicit handling
that keeps it available and explains the correction or manual recovery needed.
The retirement flag does not implement this. Today a permanent server refusal
uses the failed-change UI, a stale expense keeps an attempted snapshot as a
conflict, and a local serialization error can enter automatic retry handling.
Do not ship a breaking release that leaves old pending records in that retry
loop or silently discards them.

The existing access policy still applies: confirmed loss of membership or group
deletion clears that group's cached data and pending edits. The initial
storage baseline intentionally abandons the earlier database and its
queue; it does not transfer old offline work into the new baseline.

## Installing the update

Android reuses `AppUpdateService` and the existing `in_app_update` plugin. The
required-update action checks Play and starts or resumes an immediate update
when allowed, regardless of release priority. Unavailability or failure opens
the Play listing. Canceling keeps the app read-only and leaves Update available.
An update must be released to that user before Play can install it.

Ordinary optional update offers still check Play on launch and, with a four-hour
interval, on resume. Priority 4 or above selects the immediate flow if Play
permits it; priority alone does not enforce compatibility.

Web checks the service worker for a release, waits for installation, sends
`SKIP_WAITING`, and reloads after controller change. A release already activated
by another tab only needs a reload. If no new release is available, the notice
stays and the user can try again. OPFS, saved edits and browser app storage are
not cleared. The existing optional Restart offer remains available as well.

Already installed old builds cannot gain this handling from a server response.
Their unsupported requests are refused safely, but the clear notice and queue
preservation behavior only exist in clients that ship this implementation.

## Release policy

The app version, API contract version and local schema version are independent.
Compatible API changes keep the same API base. Future local schema migrations
keep `opensplit-v1-<accountId>` and increment the Drift schema version inside it.

For future production retirements, make a compatible app available before
removing its predecessor, or accept the period in which users cannot connect.
An update prompt cannot shorten Play review or install an unreleased build.

See [Google Play in-app updates](https://developer.android.com/guide/playcore/in-app-updates),
[availability and cancellation](https://developer.android.com/guide/playcore/in-app-updates/kotlin-java),
and [Workbox update activation](https://developer.chrome.com/docs/workbox/handling-service-worker-updates).
Loading