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
38 changes: 24 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5

- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
Expand Down Expand Up @@ -53,7 +53,7 @@ jobs:

- name: Upload Playwright failure report
if: failure()
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: playwright-report
path: playwright-report/
Expand All @@ -69,26 +69,26 @@ jobs:
id-token: write
environment:
name: production
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
steps:
- name: Check out repository
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5

- name: Validate production configuration
shell: bash
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
run: |
if [[ -z "$SUPABASE_ACCESS_TOKEN" || -z "$SUPABASE_DB_PASSWORD" || -z "$SUPABASE_PROJECT_ID" || -z "$VITE_SUPABASE_URL" || -z "$VITE_SUPABASE_PUBLISHABLE_KEY" ]]; then
echo "The production environment is missing required Supabase secrets or variables."
exit 1
fi

- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
Expand All @@ -97,19 +97,29 @@ jobs:
run: npm ci

- name: Build the production GitHub Pages artifact
env:
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
run: npm run build:pages

- name: Link the production database
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
run: npx supabase link --project-ref "$SUPABASE_PROJECT_ID"

- name: Apply pending production migrations
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
run: npx supabase db push

- name: Configure GitHub Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6

- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: dist

Expand All @@ -127,4 +137,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
9 changes: 5 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Tally is local-first rather than frontend-only. Browser persistence powers priva
- `src/features/liveSharing/useLiveActivitySession.ts` owns capability-URL synchronization, backend loading and saving, optimistic-conflict recovery, and local live shortcuts.
- `src/features/sharing/` owns text summaries, PNG generation, and browser sharing fallbacks.
- `src/pwa/` contains pure service-worker cache-manifest helpers, while `src/sw.ts` owns install, activation, and fetch lifecycle events.
- `src/analytics.ts` owns the typed, non-blocking first-party event client and frontend-only Cloudflare fallback.
- `src/analytics.ts` owns the typed, non-blocking first-party event client.
- `src/security/` contains browser-side defense-in-depth guards for hosting limitations, including clickjacking protection.

Dependencies point inward: UI features may use domain and data utilities, while domain modules never import React or feature components. Imports are direct instead of routed through a barrel file.

Expand All @@ -38,23 +39,23 @@ Safari does not reliably route ordinary HTTPS links into an installed Home Scree

## URL-state sharing experiment

`shareActivityUrl.ts` defines a versioned activity snapshot independent of the local-storage schema. New snapshots use an LZ-compressed `#share=z.…` payload, while the decoder retains compatibility with earlier base64url links. It validates every member, expense, relationship, and split before rendering a shared URL. Shared fragments open read-only, do not write to local storage, and suppress third-party analytics; first-party measurement receives only the `snapshot` surface. Saving requires the recipient to choose their participant, remaps that participant to `me`, and creates new IDs for every other imported entity so copies cannot overwrite existing records.
`shareActivityUrl.ts` defines a versioned activity snapshot independent of the local-storage schema. New snapshots use an LZ-compressed `#share=z.…` payload, while the decoder retains compatibility with earlier base64url links. It bounds the incoming token and decoded payload before validating every member, expense, relationship, and split. Shared fragments open read-only and do not write to local storage; first-party measurement receives only the `snapshot` surface. Saving requires the recipient to choose their participant, remaps that participant to `me`, and creates new IDs for every other imported entity so copies cannot overwrite existing records.

URL state is a transport rather than synchronization: every edit produces a new snapshot, and there is no canonical latest version or automatic conflict resolution.

## Live-sharing backend

Supabase stores the canonical live activity. A short activity code identifies the row, while a secret edit token in the URL fragment grants read/write access. The database stores only a SHA-256 hash of that token. Every update supplies an expected revision and increments it atomically, preventing silent last-write-wins data loss.

`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, hashed-IP request throttling, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly.
`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, secret-peppered request throttling, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly.

The frontend treats Supabase as canonical whenever a live capability is active. Local shortcut rows contain only navigation metadata and credentials; they are never a second writable activity copy. Live capabilities are trusted-group bearer credentials rather than user authorization. See `docs/LIVE_SHARING_EXPERIMENT.md` and `docs/DEPLOYMENT.md` before changing this boundary.

## Analytics boundary

The configured production build sends a fixed event enum through `public.record_analytics_event`. Local activities remain entirely in browser storage; recording a local event never uploads the activity itself. The browser sends only the event name, coarse surface, resolved `en`/`zh-CN` UI locale, and a random session-scoped token. Locale represents the app language rather than physical location. The database stores a SHA-256 hash of that token in `private.analytics_events`, and browser roles have no table or aggregate-view access.

The RPC validates every value, applies the existing hashed-IP throttle, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. Third-party Cloudflare analytics is a fallback only for frontend-only production builds and never executes on `#share=` or `#live=` URLs. See `docs/ANALYTICS.md` before adding events or properties.
The RPC validates every value, applies the existing secret-peppered request throttle, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. There is no third-party analytics fallback. See `docs/ANALYTICS.md` before adding events or properties.

## Change contract

Expand Down
4 changes: 2 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ Anyone with the complete live URL can read and edit the activity. Share it only

## Abuse protection and analytics

The backend rate-limits requests using a one-way hash of the client IP address; the raw address is not stored in the application rate-limit table.
The backend rate-limits requests using a secret-peppered one-way identifier derived from the client IP address; neither the raw address nor an unpeppered IP hash is stored in the application rate-limit table.

Production records a small allowlist of first-party product events for both local and live workflows. Each event contains only an event name, a coarse `local`, `live`, or `snapshot` surface, a one-way hash of the session token, and the event time. Analytics never receives a page URL or fragment, activity code, edit token, participant identity, activity name, expense description, amount, balance, or activity snapshot. Event rows expire after 90 days. Browser roles can write through a validated, rate-limited RPC but cannot read analytics events or reports.

Frontend-only production builds may use Cloudflare Web Analytics on ordinary app pages. The third-party beacon is always suppressed on `#share=` and `#live=` URLs so it cannot observe shared state or capability tokens.
Tally does not load a third-party analytics beacon. A deployment without Supabase analytics configuration simply records no product analytics.

## Scope

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ On first use, Tally asks for a display name and stores that identity only in the

Tally supports English and Simplified Chinese. It starts in Chinese when the browser language is Chinese or the device time zone is in mainland China; otherwise it starts in English. A manual choice in **Settings** is saved in the browser and always takes priority. Expense timestamps use the browser's IANA time zone, which is shown in Settings.

Production uses privacy-preserving first-party analytics through Supabase for both browser-local and live activity workflows. Only allowlisted event names, a coarse `local`/`live`/`snapshot` surface, the resolved `en`/`zh-CN` UI locale, an allowlisted currency code for currency-selection events, and a one-way session hash are stored—never precise location, URLs, capability tokens, activity IDs, names, descriptions, amounts, or balances. Frontend-only deployments can retain Cloudflare Web Analytics, but third-party analytics never loads on shared activity URLs. See [the analytics design](docs/ANALYTICS.md).
Production uses privacy-preserving first-party analytics through Supabase for both browser-local and live activity workflows. Only allowlisted event names, a coarse `local`/`live`/`snapshot` surface, the resolved `en`/`zh-CN` UI locale, an allowlisted currency code for currency-selection events, and a one-way session hash are stored—never precise location, URLs, capability tokens, activity IDs, names, descriptions, amounts, or balances. Tally does not load a third-party analytics beacon. See [the analytics design](docs/ANALYTICS.md).

## Sharing and live collaboration

Expand All @@ -20,7 +20,7 @@ Tally supports two deliberately different sharing modes:
- **Share live** creates a short capability URL for one canonical activity in Supabase. Trusted recipients with the complete link can load and edit the same revision-checked data from different browsers.
- **Share link** opens the device share sheet, with a separate copy-link fallback. If Safari opens the link outside the installed PWA, **Join activity** safely transfers the copied link into the existing Tally app session.

Opening a snapshot never overwrites browser data, and shared-preview URLs never load third-party analytics because the fragment contains names and expense details. First-party measurement records only the coarse `snapshot` surface. Live links keep their secret edit token in the fragment; Supabase stores only its SHA-256 hash. Every browser that successfully opens a live link keeps the latest full snapshot as a recovery copy, while Supabase remains the source of truth for as long as that Live session is available. See [the live sharing architecture](docs/LIVE_SHARING_EXPERIMENT.md) and [production deployment guide](docs/DEPLOYMENT.md).
Opening a snapshot never overwrites browser data, and Tally never loads third-party analytics. First-party measurement records only the coarse `snapshot` surface, never the fragment containing names and expense details. Live links keep their secret edit token in the fragment; Supabase stores only its SHA-256 hash. Every browser that successfully opens a live link keeps the latest full snapshot as a recovery copy, while Supabase remains the source of truth for as long as that Live session is available. See [the live sharing architecture](docs/LIVE_SHARING_EXPERIMENT.md) and [production deployment guide](docs/DEPLOYMENT.md).

[Try the live demo](https://pengfanz.github.io/splitbill/)

Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ Local activities and identities are stored in browser `localStorage`. Live activ

Anyone with a complete live URL can read and edit that activity. There are no accounts, participant-level permissions, token revocation, or audit trail in this release. Treat leaked URLs as compromised and avoid regulated or highly sensitive data.

Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. Shared snapshot URLs are decoded with strict compressed-input and expanded-output limits. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page.

Reports involving capability leakage, RPC privilege escalation, rate-limit bypasses, browser storage exposure, exported summaries, dependency vulnerabilities, or deployment configuration are in scope. Never include a real live activity URL, database password, service-role key, or Supabase access token in a report.
4 changes: 2 additions & 2 deletions docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ order by hour_of_day;

These are anonymous sessions, not authenticated users. One person can create multiple sessions, a selected UI language is not proof of physical location, and offline or self-hosted development use is not measured.

## Retention and fallback
## Retention and availability

Events older than 90 days are removed in bounded batches when an app-open event is recorded. The timestamp index keeps cleanup bounded as the table grows.

When a production build has no Supabase configuration, the existing Cloudflare Web Analytics page-view beacon remains an optional fallback. It is always disabled on `#share=` and `#live=` URLs. Development and test builds do not initialize either analytics path by default.
When a production build has no Supabase configuration, product analytics is disabled. Tally does not load a third-party page-view beacon. Development and test builds do not initialize first-party analytics by default.
6 changes: 4 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Create a project in [Supabase](https://supabase.com/dashboard), then record:
- the project URL;
- the publishable client key.

In **Integrations → Data API → Settings**, disable automatic exposure for new tables and functions when that option is available. Tally grants access only to its reviewed public RPC wrappers; application tables stay in the unexposed `private` schema. The migrations also remove anonymous default privileges for objects owned by the migration role.

Generate a personal access token from [Supabase account tokens](https://supabase.com/dashboard/account/tokens). The token and database password are deployment secrets; the project URL, project reference, and publishable key are intentionally safe client configuration.

Do not make production schema changes in the Dashboard. All schema changes must be committed under `supabase/migrations/` and released by CI, following [Supabase's migration workflow](https://supabase.com/docs/guides/deployment/database-migrations).
Expand Down Expand Up @@ -83,12 +85,12 @@ The workflow can also be started manually from `main` with **Run workflow**.
## Operational requirements

- Backend activities expire 90 days after their last successful update and expired rows are removed incrementally during new activity creation. Each browser that opened the activity keeps its latest full snapshot locally until the person removes it or clears site data; after confirmed backend expiration, that saved copy can continue as a local activity and start a new Live session.
- Create, load, and update RPCs are rate-limited per hashed client IP. Review API/database logs and tune limits from observed traffic.
- Create, load, update, and analytics RPCs are rate-limited per secret-peppered identifier derived from the client IP. Rejected requests consume the same budget as successful requests. Review API/database logs and tune limits from observed traffic.
- First-party analytics events expire after 90 days and contain no URL, capability, identity, activity, or financial payload. Review aggregate usage with the queries in [ANALYTICS.md](ANALYTICS.md).
- Free-tier projects should export regular off-site logical backups with `supabase db dump`. Paid projects provide daily backups; consider point-in-time recovery when the recovery objective warrants it. See [Supabase backups](https://supabase.com/docs/guides/platform/backups).
- Review Security Advisor and Performance Advisor after every schema change.
- If a capability URL leaks, treat the activity as compromised. Token rotation/revocation is a required follow-up before serving groups that need stronger access control.
- If a custom Supabase API domain is introduced, add its origin to the `connect-src` policy in `index.html`.
- If the Supabase project URL changes or a custom API domain is introduced, replace the exact allowed Supabase origin in the `connect-src` policy in `index.html`.

## Rollback

Expand Down
Loading